@exegia/corpora-ui 0.19.0 → 0.21.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/auth/__tests__/auth-state-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
- package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
- package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
- package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
- package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
- package/dist-lib/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.d.ts +1 -0
- 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-atom.d.ts +217 -0
- package/dist-lib/components/blocks/nav/sidebar/ai-sidebar.d.ts +11 -2
- package/dist-lib/components/blocks/nav/sidebar/index.d.ts +3 -1
- package/dist-lib/components/blocks/nav/sidebar/sidebar-context.d.ts +3 -0
- package/dist-lib/components/blocks/nav/sidebar/sidebar-row.d.ts +3 -1
- package/dist-lib/components/blocks/nav/sidebar/type.d.ts +191 -29
- package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar-state.d.ts +29 -0
- package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +27 -31
- package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +6 -1
- package/dist-lib/components/blocks/profile/index.d.ts +7 -0
- package/dist-lib/components/blocks/profile/profile-card-atom.d.ts +68 -0
- package/dist-lib/components/blocks/profile/profile-card-block.d.ts +28 -39
- package/dist-lib/components/blocks/profile/type.d.ts +80 -0
- package/dist-lib/components/blocks/profile/use-profile-card-state.d.ts +24 -0
- package/dist-lib/components/blocks/profile/use-profile-card.d.ts +34 -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/blocks/shell/__tests__/shell-fit-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/shell/__tests__/shell-metrics.test.d.ts +1 -0
- package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
- package/dist-lib/components/blocks/shell/animated-panel.d.ts +1 -1
- package/dist-lib/components/blocks/shell/index.d.ts +4 -0
- package/dist-lib/components/blocks/shell/shell-fit-atom.d.ts +68 -0
- package/dist-lib/components/blocks/shell/shell-metrics.d.ts +54 -0
- package/dist-lib/components/blocks/shell/type.d.ts +115 -3
- package/dist-lib/components/blocks/shell/use-shell-fit-state.d.ts +25 -0
- package/dist-lib/components/blocks/shell/use-shell-fit.d.ts +14 -0
- package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +12 -1
- package/dist-lib/components/blocks/shell/utils.d.ts +32 -0
- package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -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 +6 -0
- package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
- package/dist-lib/components/composed/tree/tree-context.d.ts +4 -0
- package/dist-lib/components/composed/tree/tree-node.d.ts +12 -0
- package/dist-lib/components/composed/tree/tree.d.ts +19 -0
- package/dist-lib/components/composed/tree/type.d.ts +290 -0
- package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -0
- package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
- package/dist-lib/components/composed/tree/use-tree.d.ts +24 -0
- package/dist-lib/components/composed/tree/utils.d.ts +28 -0
- package/dist-lib/components/composed/user-avatar.d.ts +5 -28
- package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
- package/dist-lib/components/user-avatar/audio-wave.d.ts +2 -0
- package/dist-lib/components/user-avatar/component.d.ts +3 -0
- package/dist-lib/components/user-avatar/fallback.d.ts +5 -0
- package/dist-lib/components/user-avatar/index.d.ts +18 -0
- package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
- package/dist-lib/components/user-avatar/type.d.ts +71 -0
- package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
- package/dist-lib/components/user-avatar/use-user-avatar.d.ts +32 -0
- package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +55 -0
- package/dist-lib/components/user-avatar/utils.d.ts +2 -0
- package/dist-lib/index.d.ts +6 -2
- package/dist-lib/index.js +3576 -1366
- package/dist-lib/index.js.map +1 -1
- package/dist-lib/state/exegia-provider.d.ts +51 -0
- package/dist-lib/state/index.d.ts +4 -0
- package/dist-lib/state/store.d.ts +17 -0
- package/package.json +16 -12
- package/src/components/beste/piece/browser-frame.tsx +9 -6
- package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
- package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
- package/src/components/blocks/auth/auth-session-atom.ts +82 -0
- package/src/components/blocks/auth/auth-state-type.ts +97 -0
- package/src/components/blocks/auth/auth-state.ts +52 -0
- package/src/components/blocks/auth/use-auth-state.ts +127 -0
- package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
- package/src/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.tsx +310 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +112 -76
- package/src/components/blocks/nav/sidebar/index.ts +53 -1
- package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
- package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
- package/src/components/blocks/nav/sidebar/type.ts +228 -29
- package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
- package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +455 -225
- package/src/components/blocks/nav/sidebar/utils.ts +29 -1
- package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
- package/src/components/blocks/profile/index.ts +28 -0
- package/src/components/blocks/profile/profile-card-atom.ts +247 -0
- package/src/components/blocks/profile/profile-card-block.tsx +153 -74
- package/src/components/blocks/profile/type.ts +95 -0
- package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
- package/src/components/blocks/profile/use-profile-card.ts +126 -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/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
- package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
- package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
- package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
- package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
- package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
- package/src/components/blocks/shell/animated-panel.tsx +126 -29
- package/src/components/blocks/shell/index.ts +18 -0
- package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
- package/src/components/blocks/shell/shell-layout.tsx +55 -53
- package/src/components/blocks/shell/shell-metrics.ts +79 -0
- package/src/components/blocks/shell/type.ts +130 -3
- package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
- package/src/components/blocks/shell/use-shell-fit.ts +135 -0
- package/src/components/blocks/shell/use-shell-panels.ts +57 -4
- package/src/components/blocks/shell/utils.ts +44 -4
- package/src/components/composed/tree/CLAUDE.md +132 -0
- package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
- package/src/components/composed/tree/__tests__/tree.test.tsx +525 -0
- package/src/components/composed/tree/__tests__/use-tree.test.tsx +333 -0
- package/src/components/composed/tree/constants.ts +60 -0
- package/src/components/composed/tree/index.ts +51 -0
- package/src/components/composed/tree/tree-atom.ts +590 -0
- package/src/components/composed/tree/tree-context.ts +13 -0
- package/src/components/composed/tree/tree-node.tsx +490 -0
- package/src/components/composed/tree/tree.tsx +286 -0
- package/src/components/composed/tree/type.ts +322 -0
- package/src/components/composed/tree/use-tree-dnd.ts +179 -0
- package/src/components/composed/tree/use-tree-state.ts +105 -0
- package/src/components/composed/tree/use-tree.ts +307 -0
- package/src/components/composed/tree/utils.ts +172 -0
- package/src/components/composed/user-avatar.tsx +11 -96
- package/src/components/docs/component-preview.tsx +11 -11
- package/src/components/user-avatar/__tests__/user-avatar.test.tsx +245 -0
- package/src/components/user-avatar/audio-wave.tsx +22 -0
- package/src/components/user-avatar/component.tsx +152 -0
- package/src/components/user-avatar/fallback.tsx +26 -0
- package/src/components/user-avatar/index.ts +34 -0
- package/src/components/user-avatar/presence-badge.tsx +56 -0
- package/src/components/user-avatar/type.ts +85 -0
- package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
- package/src/components/user-avatar/use-user-avatar.ts +144 -0
- package/src/components/user-avatar/user-avatar-atom.ts +198 -0
- package/src/components/user-avatar/utils.ts +8 -0
- package/src/index.css +211 -153
- package/src/index.ts +35 -2
- package/src/state/exegia-provider.tsx +79 -0
- package/src/state/index.ts +4 -0
- package/src/state/store.ts +19 -0
|
@@ -0,0 +1,152 @@
|
|
|
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 { PresenceBadge } from "./presence-badge"
|
|
9
|
+
import type { UserAvatarProps } from "./type"
|
|
10
|
+
import { useUserAvatar } from "./use-user-avatar"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Identity avatar: an image when one is given, initials otherwise, with an
|
|
14
|
+
* optional presence badge and a pointer-lit bezel.
|
|
15
|
+
*
|
|
16
|
+
* A passed `src` is assumed to be remote, so the slot holds a skeleton until
|
|
17
|
+
* the image resolves rather than flashing initials that are about to be
|
|
18
|
+
* replaced. A failed load settles on the initials.
|
|
19
|
+
*
|
|
20
|
+
* State (presence, bezel angle, image status) lives in Jotai atoms keyed by
|
|
21
|
+
* `avatarId` — name the avatar and `useUserAvatarActions(id).setPresence()`
|
|
22
|
+
* flips its badge from anywhere under `ExegiaProvider`.
|
|
23
|
+
*/
|
|
24
|
+
/** The rim (and the badge sheen) trail the pointer's bearing slightly rather
|
|
25
|
+
* than pointing dead at it — a touch of lag reads as weight. */
|
|
26
|
+
const BEZEL_DAMPING = 0.8
|
|
27
|
+
|
|
28
|
+
export function UserAvatar({
|
|
29
|
+
src,
|
|
30
|
+
name = "",
|
|
31
|
+
initials,
|
|
32
|
+
alt,
|
|
33
|
+
loading: loadingProp,
|
|
34
|
+
presence: presenceProp,
|
|
35
|
+
bezel = true,
|
|
36
|
+
avatarId,
|
|
37
|
+
className,
|
|
38
|
+
style,
|
|
39
|
+
...props
|
|
40
|
+
}: UserAvatarProps): React.ReactElement {
|
|
41
|
+
const hasImage = src !== undefined
|
|
42
|
+
const avatar = useUserAvatar({
|
|
43
|
+
avatarId,
|
|
44
|
+
presence: presenceProp,
|
|
45
|
+
bezel,
|
|
46
|
+
hasImage,
|
|
47
|
+
})
|
|
48
|
+
const { presence, bezelAngle, imageStatus, setImageStatus, ref } = avatar
|
|
49
|
+
|
|
50
|
+
const loading =
|
|
51
|
+
loadingProp ??
|
|
52
|
+
(hasImage && (imageStatus === "idle" || imageStatus === "loading"))
|
|
53
|
+
// A photo is actually on screen — not merely requested. A broken or
|
|
54
|
+
// still-loading `src` shows initials/skeleton and should be treated as
|
|
55
|
+
// the flat disc it is.
|
|
56
|
+
const showsImage = hasImage && loadingProp !== true && imageStatus === "loaded"
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
// The frame is what the badge and bezel hang off: the Avatar root clips
|
|
60
|
+
// to its circle, and the badge has to sit on the rim, half outside it.
|
|
61
|
+
// Sizing classes still target the Avatar — the frame shrink-wraps it.
|
|
62
|
+
<span
|
|
63
|
+
className="relative inline-flex shrink-0 rounded-full"
|
|
64
|
+
data-slot="user-avatar-frame"
|
|
65
|
+
ref={ref as React.RefObject<HTMLSpanElement>}
|
|
66
|
+
>
|
|
67
|
+
<Avatar
|
|
68
|
+
className={cn(
|
|
69
|
+
"size-8 border-1 border-white bg-neutral-800! text-xl dark:border-neutral-900",
|
|
70
|
+
// The emboss itself: an inset drop toward the light's opposite side
|
|
71
|
+
// is what the ring below is rotated against.
|
|
72
|
+
bezel &&
|
|
73
|
+
"shadow-[inset_0_3px_3px_--theme(--color-white/80%),inset_0_-1px_2px_--theme(--color-black/15%)] dark:shadow-[inset_0_1px_1px_--theme(--color-white/5%),inset_0_-8px_2px_--theme(--color-black/20%)]",
|
|
74
|
+
className
|
|
75
|
+
)}
|
|
76
|
+
data-avatar-id={avatar.avatarId}
|
|
77
|
+
data-bezel={bezel ? "" : undefined}
|
|
78
|
+
data-loading={loading || undefined}
|
|
79
|
+
data-presence={presence ?? undefined}
|
|
80
|
+
data-slot="user-avatar"
|
|
81
|
+
style={style}
|
|
82
|
+
{...props}
|
|
83
|
+
>
|
|
84
|
+
{/* Held back while forced-loading: a loaded image would hide the
|
|
85
|
+
fallback the skeleton lives in. */}
|
|
86
|
+
{!hasImage || loadingProp === true ? null : (
|
|
87
|
+
<AvatarImage
|
|
88
|
+
alt={alt ?? name}
|
|
89
|
+
onLoadingStatusChange={setImageStatus}
|
|
90
|
+
src={src}
|
|
91
|
+
/>
|
|
92
|
+
)}
|
|
93
|
+
<Fallback name={name} initials={initials} loading={loading} />
|
|
94
|
+
</Avatar>
|
|
95
|
+
{presence === "online" ? (
|
|
96
|
+
// The badge's green "reflected" onto the disc: a static radial wash
|
|
97
|
+
// anchored at the badge corner that fades inward, so the dot reads
|
|
98
|
+
// as a light source sitting on the avatar rather than a sticker on
|
|
99
|
+
// it. Does not move with the bezel — it belongs to the badge, not
|
|
100
|
+
// the pointer. Sits above the image, below the bezel and the badge.
|
|
101
|
+
<span
|
|
102
|
+
aria-hidden="true"
|
|
103
|
+
className={cn(
|
|
104
|
+
"pointer-events-none absolute inset-0 rounded-full",
|
|
105
|
+
// One gradient, four knobs: colour, peak alpha, mid alpha, reach.
|
|
106
|
+
// Light mode is quieter than dark (a green wash on a pale disc
|
|
107
|
+
// reads as a stain, on a dark one as a glow), and an initials
|
|
108
|
+
// disc — flat, no photo to sit on — takes a smaller, fainter
|
|
109
|
+
// pool than an image does.
|
|
110
|
+
"[--reflect:--theme(--color-emerald-500)] dark:[--reflect:--theme(--color-emerald-400)]",
|
|
111
|
+
showsImage
|
|
112
|
+
? "[--reflect-peak:30%] [--reflect-mid:12%] [--reflect-reach:56%] dark:[--reflect-peak:40%] dark:[--reflect-mid:16%] dark:[--reflect-reach:60%]"
|
|
113
|
+
: "[--reflect-peak:16%] [--reflect-mid:6%] [--reflect-reach:40%] dark:[--reflect-peak:26%] dark:[--reflect-mid:9%] dark:[--reflect-reach:46%]",
|
|
114
|
+
"bg-[radial-gradient(circle_at_88%_88%,color-mix(in_oklab,var(--reflect)_var(--reflect-peak),transparent)_0%,color-mix(in_oklab,var(--reflect)_var(--reflect-mid),transparent)_22%,transparent_var(--reflect-reach))]",
|
|
115
|
+
"transition-opacity duration-150 ease-smooth-out"
|
|
116
|
+
)}
|
|
117
|
+
data-image={showsImage ? "" : undefined}
|
|
118
|
+
data-slot="user-avatar-reflection"
|
|
119
|
+
/>
|
|
120
|
+
) : null}
|
|
121
|
+
{bezel ? (
|
|
122
|
+
// A conic highlight → shadow sweep, masked to a thin rim and rotated
|
|
123
|
+
// so the highlight faces the pointer. Rotation (not the gradient
|
|
124
|
+
// angle) is what moves, because `transform` transitions and a
|
|
125
|
+
// gradient stop does not. Light and dark pick their own alphas via
|
|
126
|
+
// the two CSS vars; the gradient reads them.
|
|
127
|
+
<span
|
|
128
|
+
aria-hidden="true"
|
|
129
|
+
className={cn(
|
|
130
|
+
"pointer-events-none absolute inset-0 rounded-full",
|
|
131
|
+
"[--bezel-hi:--theme(--color-white)] [--bezel-lo:--theme(--color-black/20%)]",
|
|
132
|
+
"dark:[--bezel-hi:--theme(--color-white/18%)] dark:[--bezel-lo:--theme(--color-black/40%)]",
|
|
133
|
+
// Starts at 6 o'clock: shadow there, highlight peaking at 12 (50%),
|
|
134
|
+
// symmetric either side — so rotate(angle) puts the highlight at
|
|
135
|
+
// exactly `bezelAngle` clockwise from 12.
|
|
136
|
+
"bg-[conic-gradient(from_180deg,var(--bezel-lo)_0%,transparent_24%,transparent_28%,var(--bezel-hi)_50%,transparent_72%,transparent_76%,var(--bezel-lo)_100%)]",
|
|
137
|
+
"[mask-image:radial-gradient(farthest-side,transparent_calc(95%-4px),#000_calc(100%-2px))]",
|
|
138
|
+
"mix-blend-normal transition-transform duration-150 ease-smooth-out motion-reduce:transition-none"
|
|
139
|
+
)}
|
|
140
|
+
data-slot="user-avatar-bezel"
|
|
141
|
+
style={{ transform: `rotate(${bezelAngle * BEZEL_DAMPING}deg)` }}
|
|
142
|
+
/>
|
|
143
|
+
) : null}
|
|
144
|
+
{presence ? (
|
|
145
|
+
<PresenceBadge
|
|
146
|
+
lightAngle={bezel ? bezelAngle * BEZEL_DAMPING : undefined}
|
|
147
|
+
presence={presence}
|
|
148
|
+
/>
|
|
149
|
+
) : null}
|
|
150
|
+
</span>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
@@ -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,34 @@
|
|
|
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 { PresenceBadge } from "./presence-badge"
|
|
9
|
+
export { initialsFrom } from "./utils"
|
|
10
|
+
export { useUserAvatar } from "./use-user-avatar"
|
|
11
|
+
export type { UseUserAvatarOptions, UserAvatarBinding } from "./use-user-avatar"
|
|
12
|
+
export { useUserAvatarActions, useUserAvatarState } from "./use-user-avatar-state"
|
|
13
|
+
// The public atom surface. `@internal` atoms (config, image status, the
|
|
14
|
+
// props projection) stay unexported on purpose — `export *` would make every
|
|
15
|
+
// internal a breaking-change surface for consumer apps.
|
|
16
|
+
export {
|
|
17
|
+
DEFAULT_BEZEL_ANGLE,
|
|
18
|
+
removeUserAvatarInstance,
|
|
19
|
+
resetUserAvatarAtom,
|
|
20
|
+
setUserAvatarBezelAngleAtom,
|
|
21
|
+
setUserAvatarPresenceAtom,
|
|
22
|
+
toggleUserAvatarPresenceAtom,
|
|
23
|
+
userAvatarBezelAngleAtom,
|
|
24
|
+
userAvatarIsOnlineAtom,
|
|
25
|
+
userAvatarPresenceAtom,
|
|
26
|
+
userAvatarStateAtom,
|
|
27
|
+
} from "./user-avatar-atom"
|
|
28
|
+
export type * from "./type"
|
|
29
|
+
|
|
30
|
+
export const Avatar = {
|
|
31
|
+
AudioWave,
|
|
32
|
+
Component: UserAvatar,
|
|
33
|
+
Fallback,
|
|
34
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils"
|
|
2
|
+
import type { UserPresence } from "./type"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Corner dot naming whether the person is reachable. Sits on the avatar
|
|
6
|
+
* frame, outside the clipped circle, with a background-coloured ring so it
|
|
7
|
+
* reads against any image. Colour is not the only signal: `offline` is a
|
|
8
|
+
* hollow ring, `online` a filled dot, and the label is spelled out for AT.
|
|
9
|
+
*
|
|
10
|
+
* `lightAngle` (degrees clockwise from 12) lays a faint sheen across the dot
|
|
11
|
+
* from that side — the same angle the avatar's bezel is lit from, so the two
|
|
12
|
+
* highlights agree and the badge reads as a bead sitting in the same light.
|
|
13
|
+
*/
|
|
14
|
+
export function PresenceBadge({
|
|
15
|
+
presence,
|
|
16
|
+
lightAngle,
|
|
17
|
+
className,
|
|
18
|
+
}: {
|
|
19
|
+
presence: UserPresence
|
|
20
|
+
lightAngle?: number
|
|
21
|
+
className?: string
|
|
22
|
+
}) {
|
|
23
|
+
const online = presence === "online"
|
|
24
|
+
return (
|
|
25
|
+
<span
|
|
26
|
+
aria-label={online ? "Online" : "Offline"}
|
|
27
|
+
className={cn(
|
|
28
|
+
"absolute right-0 bottom-0 box-content size-[22%] min-h-1.5 min-w-1.5 overflow-hidden rounded-full",
|
|
29
|
+
"ring-2 ring-background transition-colors duration-150 ease-smooth-out",
|
|
30
|
+
online
|
|
31
|
+
? "bg-emerald-500 dark:bg-emerald-400"
|
|
32
|
+
: "border-2 border-muted-foreground/60 bg-background dark:border-muted-foreground/70",
|
|
33
|
+
className
|
|
34
|
+
)}
|
|
35
|
+
data-presence={presence}
|
|
36
|
+
data-slot="user-avatar-presence"
|
|
37
|
+
role="img"
|
|
38
|
+
>
|
|
39
|
+
{lightAngle !== undefined ? (
|
|
40
|
+
// Highlight at 12 o'clock, fading out by the middle, rotated so it
|
|
41
|
+
// faces the light. Rotation transitions; the gradient is static.
|
|
42
|
+
<span
|
|
43
|
+
aria-hidden="true"
|
|
44
|
+
className={cn(
|
|
45
|
+
"pointer-events-none absolute inset-0 rounded-full",
|
|
46
|
+
"bg-[linear-gradient(to_bottom,--theme(--color-white/55%),--theme(--color-white/12%)_38%,transparent_58%)]",
|
|
47
|
+
"dark:bg-[linear-gradient(to_bottom,--theme(--color-white/40%),--theme(--color-white/8%)_38%,transparent_58%)]",
|
|
48
|
+
"transition-transform duration-150 ease-smooth-out motion-reduce:transition-none"
|
|
49
|
+
)}
|
|
50
|
+
data-slot="user-avatar-presence-sheen"
|
|
51
|
+
style={{ transform: `rotate(${lightAngle}deg)` }}
|
|
52
|
+
/>
|
|
53
|
+
) : null}
|
|
54
|
+
</span>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
/** Whether the person is reachable right now — drawn as the corner badge. */
|
|
10
|
+
export type UserPresence = "online" | "offline"
|
|
11
|
+
|
|
12
|
+
/** Key for one avatar's state in the store. Any stable string; `useUserAvatar`
|
|
13
|
+
* generates one when the component does not name itself. */
|
|
14
|
+
export type UserAvatarInstanceId = string
|
|
15
|
+
|
|
16
|
+
export interface UserAvatarProps extends Omit<
|
|
17
|
+
AvatarPrimitive.Root.Props,
|
|
18
|
+
"children"
|
|
19
|
+
> {
|
|
20
|
+
/** Image URL. Without one the initials show immediately — no skeleton. */
|
|
21
|
+
src?: string
|
|
22
|
+
/** Drives the initials, and the alt text unless `alt` overrides it. */
|
|
23
|
+
name?: string
|
|
24
|
+
/** Overrides the initials derived from `name`. */
|
|
25
|
+
initials?: string
|
|
26
|
+
/**
|
|
27
|
+
* Alt text for the image. Pass "" when adjacent text already names the
|
|
28
|
+
* person — the image is then decorative.
|
|
29
|
+
*/
|
|
30
|
+
alt?: string
|
|
31
|
+
/**
|
|
32
|
+
* Force the skeleton, for when the identity itself is still being fetched.
|
|
33
|
+
* Omitted, it follows the image: a passed `src` skeletons until it resolves.
|
|
34
|
+
*/
|
|
35
|
+
loading?: boolean
|
|
36
|
+
/**
|
|
37
|
+
* Corner badge: a green dot for `online`, a hollow grey ring for `offline`.
|
|
38
|
+
* Omitted, no badge — unless the avatar is driven by id and something set
|
|
39
|
+
* its presence in the store. Controlled when passed.
|
|
40
|
+
*/
|
|
41
|
+
presence?: UserPresence
|
|
42
|
+
/**
|
|
43
|
+
* Embossed rim whose highlight follows the pointer, so the avatar reads as
|
|
44
|
+
* a lit bezel rather than a flat disc. Light and dark aware; static under
|
|
45
|
+
* reduced motion. On by default — pass `false` for a flat disc.
|
|
46
|
+
*/
|
|
47
|
+
bezel?: boolean
|
|
48
|
+
/**
|
|
49
|
+
* Name this avatar's slice of the store so `useUserAvatarState(id)` /
|
|
50
|
+
* `useUserAvatarActions(id)` can read or drive it from anywhere under
|
|
51
|
+
* `ExegiaProvider`. Unnamed avatars key off `useId` and are dropped on
|
|
52
|
+
* unmount.
|
|
53
|
+
*/
|
|
54
|
+
avatarId?: UserAvatarInstanceId
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Everything the store knows about one avatar. */
|
|
58
|
+
export interface UserAvatarState {
|
|
59
|
+
presence: UserPresence | null
|
|
60
|
+
/** Bezel light direction in degrees, clockwise from 12 o'clock. Continuous
|
|
61
|
+
* (may leave [0, 360)) so the rim's CSS rotation never spins the long way;
|
|
62
|
+
* `((a % 360) + 360) % 360` if you need it normalised. */
|
|
63
|
+
bezelAngle: number
|
|
64
|
+
imageStatus: ImageStatus
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Write-only handles onto one avatar. Nothing here re-renders the caller. */
|
|
68
|
+
export interface UserAvatarActions {
|
|
69
|
+
setPresence: (presence: UserPresence | null) => void
|
|
70
|
+
togglePresence: () => void
|
|
71
|
+
setBezelAngle: (angle: number) => void
|
|
72
|
+
reset: () => void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @internal Which fields the mounted component controls from props. The
|
|
76
|
+
* store must never overwrite a controlled value. */
|
|
77
|
+
export interface UserAvatarConfig {
|
|
78
|
+
controlsPresence: boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface IAudioWaveProps {
|
|
82
|
+
className?: string
|
|
83
|
+
children?: React.ReactNode
|
|
84
|
+
volume?: number
|
|
85
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { useAtomValue, useSetAtom } from "jotai"
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
UserAvatarActions,
|
|
8
|
+
UserAvatarInstanceId,
|
|
9
|
+
UserAvatarState,
|
|
10
|
+
} from "./type"
|
|
11
|
+
import {
|
|
12
|
+
resetUserAvatarAtom,
|
|
13
|
+
setUserAvatarBezelAngleAtom,
|
|
14
|
+
setUserAvatarPresenceAtom,
|
|
15
|
+
toggleUserAvatarPresenceAtom,
|
|
16
|
+
userAvatarStateAtom,
|
|
17
|
+
} from "./user-avatar-atom"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read the avatar registered under `avatarId` from anywhere below
|
|
21
|
+
* `ExegiaProvider` — no props, no ref, no provider of its own.
|
|
22
|
+
*
|
|
23
|
+
* ```tsx
|
|
24
|
+
* const { presence } = useUserAvatarState("me")
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Returns the whole state object, so the caller re-renders on any change —
|
|
28
|
+
* including every bezel frame while a pointer moves. A component that only
|
|
29
|
+
* cares about presence should subscribe to that atom instead:
|
|
30
|
+
* `useAtomValue(userAvatarPresenceAtom("me"))`.
|
|
31
|
+
*/
|
|
32
|
+
export function useUserAvatarState(
|
|
33
|
+
avatarId: UserAvatarInstanceId
|
|
34
|
+
): UserAvatarState {
|
|
35
|
+
return useAtomValue(userAvatarStateAtom(avatarId))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Write-only handles onto the avatar registered under `avatarId`. Nothing
|
|
40
|
+
* here subscribes, so a presence indicator elsewhere in the app can flip
|
|
41
|
+
* the badge without re-rendering when the avatar changes.
|
|
42
|
+
*
|
|
43
|
+
* ```tsx
|
|
44
|
+
* const { setPresence } = useUserAvatarActions("me")
|
|
45
|
+
* socket.on("presence", (p) => setPresence(p))
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function useUserAvatarActions(
|
|
49
|
+
avatarId: UserAvatarInstanceId
|
|
50
|
+
): UserAvatarActions {
|
|
51
|
+
const setPresence = useSetAtom(setUserAvatarPresenceAtom(avatarId))
|
|
52
|
+
const togglePresence = useSetAtom(toggleUserAvatarPresenceAtom(avatarId))
|
|
53
|
+
const setBezelAngle = useSetAtom(setUserAvatarBezelAngleAtom(avatarId))
|
|
54
|
+
const reset = useSetAtom(resetUserAvatarAtom(avatarId))
|
|
55
|
+
|
|
56
|
+
return React.useMemo(
|
|
57
|
+
() => ({ setPresence, togglePresence, setBezelAngle, reset }),
|
|
58
|
+
[setPresence, togglePresence, setBezelAngle, reset]
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { useAtomValue, useSetAtom } from "jotai"
|
|
5
|
+
import { useReducedMotion } from "motion/react"
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
ImageStatus,
|
|
9
|
+
UserAvatarConfig,
|
|
10
|
+
UserAvatarInstanceId,
|
|
11
|
+
UserPresence,
|
|
12
|
+
} from "./type"
|
|
13
|
+
import {
|
|
14
|
+
projectUserAvatarPropsAtom,
|
|
15
|
+
removeUserAvatarInstance,
|
|
16
|
+
setUserAvatarBezelAngleAtom,
|
|
17
|
+
setUserAvatarImageStatusAtom,
|
|
18
|
+
userAvatarBezelAngleAtom,
|
|
19
|
+
userAvatarImageStatusAtom,
|
|
20
|
+
userAvatarPresenceAtom,
|
|
21
|
+
} from "./user-avatar-atom"
|
|
22
|
+
|
|
23
|
+
export interface UseUserAvatarOptions {
|
|
24
|
+
avatarId?: UserAvatarInstanceId
|
|
25
|
+
/** Controlled presence. `undefined` leaves the store in charge. */
|
|
26
|
+
presence?: UserPresence
|
|
27
|
+
/** Track the pointer for the bezel highlight. */
|
|
28
|
+
bezel: boolean
|
|
29
|
+
/** Only meaningful with an image — no image, no status to track. */
|
|
30
|
+
hasImage: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UserAvatarBinding {
|
|
34
|
+
avatarId: UserAvatarInstanceId
|
|
35
|
+
presence: UserPresence | null
|
|
36
|
+
bezelAngle: number
|
|
37
|
+
imageStatus: ImageStatus
|
|
38
|
+
/** Hand to the image's `onLoadingStatusChange`. Stable. */
|
|
39
|
+
setImageStatus: (status: ImageStatus) => void
|
|
40
|
+
/** Attach to the avatar root — the bezel is lit relative to its centre. */
|
|
41
|
+
ref: React.RefObject<HTMLElement | null>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Below this the angle write is skipped: a rim highlight cannot show a
|
|
45
|
+
* sub-degree change, and skipping keeps the pointer loop store-quiet. */
|
|
46
|
+
const ANGLE_EPSILON = 1.5
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Binds one avatar to its slice of the store: projects the controlled
|
|
50
|
+
* `presence` prop, tracks the image status, and — while `bezel` is on —
|
|
51
|
+
* follows the pointer with a rAF-coalesced `pointermove` listener that turns
|
|
52
|
+
* the pointer's bearing from the avatar's centre into `bezelAngle`.
|
|
53
|
+
*
|
|
54
|
+
* Unnamed avatars key off `useId` and are dropped from the store on unmount;
|
|
55
|
+
* a named `avatarId` outlives its component so a presence set elsewhere
|
|
56
|
+
* survives a remount.
|
|
57
|
+
*/
|
|
58
|
+
export function useUserAvatar(options: UseUserAvatarOptions): UserAvatarBinding {
|
|
59
|
+
const { presence: presenceProp, bezel, hasImage } = options
|
|
60
|
+
const generatedId = React.useId()
|
|
61
|
+
const avatarId = options.avatarId ?? generatedId
|
|
62
|
+
const controlsPresence = presenceProp !== undefined
|
|
63
|
+
|
|
64
|
+
const config = React.useMemo<UserAvatarConfig>(
|
|
65
|
+
() => ({ controlsPresence }),
|
|
66
|
+
[controlsPresence]
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const project = useSetAtom(projectUserAvatarPropsAtom(avatarId))
|
|
70
|
+
const setBezelAngle = useSetAtom(setUserAvatarBezelAngleAtom(avatarId))
|
|
71
|
+
const setImageStatus = useSetAtom(setUserAvatarImageStatusAtom(avatarId))
|
|
72
|
+
|
|
73
|
+
// Layout effect: the projection must land before children read the store
|
|
74
|
+
// in the same commit. Primitive deps, so no loop guard is needed here.
|
|
75
|
+
React.useLayoutEffect(() => {
|
|
76
|
+
project(config, presenceProp)
|
|
77
|
+
}, [project, config, presenceProp])
|
|
78
|
+
|
|
79
|
+
// A generated key belongs to this mount only; a given `avatarId` is the
|
|
80
|
+
// app's handle and outlives it.
|
|
81
|
+
React.useEffect(() => {
|
|
82
|
+
if (options.avatarId !== undefined) return
|
|
83
|
+
return () => removeUserAvatarInstance(avatarId)
|
|
84
|
+
}, [options.avatarId, avatarId])
|
|
85
|
+
|
|
86
|
+
const presence = useAtomValue(userAvatarPresenceAtom(avatarId))
|
|
87
|
+
const bezelAngle = useAtomValue(userAvatarBezelAngleAtom(avatarId))
|
|
88
|
+
const imageStatus = useAtomValue(userAvatarImageStatusAtom(avatarId))
|
|
89
|
+
|
|
90
|
+
const ref = React.useRef<HTMLElement | null>(null)
|
|
91
|
+
const reduce = useReducedMotion()
|
|
92
|
+
|
|
93
|
+
// Pointer → bearing. Coalesced to one write per animation frame, and only
|
|
94
|
+
// when the angle moved enough to matter, so a room full of avatars is
|
|
95
|
+
// still one cheap listener each and near-zero store traffic while idle.
|
|
96
|
+
React.useEffect(() => {
|
|
97
|
+
if (!bezel || reduce || typeof window === "undefined") return
|
|
98
|
+
let frame = 0
|
|
99
|
+
let last = Number.NaN
|
|
100
|
+
let pointer: { x: number; y: number } | null = null
|
|
101
|
+
|
|
102
|
+
const flush = () => {
|
|
103
|
+
frame = 0
|
|
104
|
+
const host = ref.current
|
|
105
|
+
if (!host || !pointer) return
|
|
106
|
+
const rect = host.getBoundingClientRect()
|
|
107
|
+
if (rect.width === 0) return
|
|
108
|
+
const dx = pointer.x - (rect.left + rect.width / 2)
|
|
109
|
+
const dy = pointer.y - (rect.top + rect.height / 2)
|
|
110
|
+
// atan2 measures from 3 o'clock anticlockwise-positive in screen
|
|
111
|
+
// space; rotate so 0 is 12 o'clock and clockwise grows.
|
|
112
|
+
const bearing = ((Math.atan2(dy, dx) * 180) / Math.PI + 90 + 360) % 360
|
|
113
|
+
// Unwrap: step from the last value by the shortest arc, so the stored
|
|
114
|
+
// angle is continuous and the rim never spins the long way round.
|
|
115
|
+
let angle = bearing
|
|
116
|
+
if (!Number.isNaN(last)) {
|
|
117
|
+
let delta = bearing - (((last % 360) + 360) % 360)
|
|
118
|
+
if (delta > 180) delta -= 360
|
|
119
|
+
if (delta < -180) delta += 360
|
|
120
|
+
if (Math.abs(delta) < ANGLE_EPSILON) return
|
|
121
|
+
angle = last + delta
|
|
122
|
+
}
|
|
123
|
+
last = angle
|
|
124
|
+
setBezelAngle(angle)
|
|
125
|
+
}
|
|
126
|
+
const onMove = (event: PointerEvent) => {
|
|
127
|
+
pointer = { x: event.clientX, y: event.clientY }
|
|
128
|
+
if (frame === 0) frame = window.requestAnimationFrame(flush)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
window.addEventListener("pointermove", onMove, { passive: true })
|
|
132
|
+
return () => {
|
|
133
|
+
window.removeEventListener("pointermove", onMove)
|
|
134
|
+
if (frame !== 0) window.cancelAnimationFrame(frame)
|
|
135
|
+
}
|
|
136
|
+
}, [bezel, reduce, setBezelAngle])
|
|
137
|
+
|
|
138
|
+
// No image, no status: keep the store honest when `src` goes away.
|
|
139
|
+
React.useEffect(() => {
|
|
140
|
+
if (!hasImage) setImageStatus("idle")
|
|
141
|
+
}, [hasImage, setImageStatus])
|
|
142
|
+
|
|
143
|
+
return { avatarId, presence, bezelAngle, imageStatus, setImageStatus, ref }
|
|
144
|
+
}
|