@brdgapp/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,210 @@
1
+ "use client"
2
+
3
+ import { useEffect, useLayoutEffect, useRef, useState } from "react"
4
+ import { createPortal } from "react-dom"
5
+ import { CheckIcon, CopyIcon, LinkIcon, MoreIcon, UserIcon, UserPlusIcon } from "./icons"
6
+ import type { BridgeClientConfig, NetworkContact, NetworkPersonAction } from "./types"
7
+
8
+ function introUrl(inboxUrl: string, email: string): string {
9
+ const url = new URL(inboxUrl)
10
+ url.searchParams.set("action", "make-intro")
11
+ url.searchParams.set("n1", email)
12
+ return url.toString()
13
+ }
14
+
15
+ export function PersonMenu({
16
+ contact,
17
+ customActions,
18
+ config,
19
+ }: {
20
+ contact: NetworkContact
21
+ customActions: NetworkPersonAction[]
22
+ config: BridgeClientConfig
23
+ }) {
24
+ const [open, setOpen] = useState(false)
25
+ const [copied, setCopied] = useState(false)
26
+ const buttonRef = useRef<HTMLButtonElement>(null)
27
+ const menuRef = useRef<HTMLDivElement>(null)
28
+ const [position, setPosition] = useState<{ top: number; left: number } | null>(null)
29
+ const profileUrl = contact.username ? config.profileUrlForUsername(contact.username) : null
30
+ const makeIntro = contact.email ? introUrl(config.introInboxUrl, contact.email) : null
31
+ const hasActions = customActions.length > 0 || Boolean(makeIntro || contact.shareLink || profileUrl)
32
+
33
+ useLayoutEffect(() => {
34
+ if (!open || !buttonRef.current) return
35
+ function place() {
36
+ const rect = buttonRef.current?.getBoundingClientRect()
37
+ if (!rect) return
38
+ const width = 248
39
+ const left = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8))
40
+ const estimatedHeight = menuRef.current?.offsetHeight ?? 180
41
+ const below = rect.bottom + 6
42
+ const top = below + estimatedHeight > window.innerHeight - 8 ? Math.max(8, rect.top - estimatedHeight - 6) : below
43
+ setPosition({ top, left })
44
+ }
45
+ place()
46
+ window.addEventListener("resize", place)
47
+ window.addEventListener("scroll", place, true)
48
+ return () => {
49
+ window.removeEventListener("resize", place)
50
+ window.removeEventListener("scroll", place, true)
51
+ }
52
+ }, [open])
53
+
54
+ useEffect(() => {
55
+ if (!open) return
56
+ function onPointer(event: MouseEvent) {
57
+ const target = event.target
58
+ if (!(target instanceof Node)) return
59
+ if (buttonRef.current?.contains(target) || menuRef.current?.contains(target)) return
60
+ setOpen(false)
61
+ setCopied(false)
62
+ }
63
+ function onKey(event: KeyboardEvent) {
64
+ if (event.key === "Escape") {
65
+ setOpen(false)
66
+ setCopied(false)
67
+ }
68
+ }
69
+ document.addEventListener("mousedown", onPointer)
70
+ document.addEventListener("keydown", onKey)
71
+ return () => {
72
+ document.removeEventListener("mousedown", onPointer)
73
+ document.removeEventListener("keydown", onKey)
74
+ }
75
+ }, [open])
76
+
77
+ if (!hasActions) return null
78
+
79
+ async function copyIntroLink() {
80
+ if (!contact.shareLink) return
81
+ try {
82
+ await navigator.clipboard.writeText(contact.shareLink)
83
+ setCopied(true)
84
+ window.setTimeout(() => {
85
+ setCopied(false)
86
+ setOpen(false)
87
+ }, 1200)
88
+ } catch {
89
+ // Clipboard can fail in restricted contexts.
90
+ }
91
+ }
92
+
93
+ const menu = open ? (
94
+ <div
95
+ ref={menuRef}
96
+ className="bridge-network-helper__person-menu-content"
97
+ style={position ? { top: position.top, left: position.left } : { visibility: "hidden" }}
98
+ role="menu"
99
+ onClick={(event) => event.stopPropagation()}
100
+ >
101
+ {customActions.map((action) => (
102
+ <button
103
+ key={action.id}
104
+ type="button"
105
+ role="menuitem"
106
+ className={
107
+ action.description
108
+ ? "bridge-network-helper__person-menu-item has-description"
109
+ : "bridge-network-helper__person-menu-item"
110
+ }
111
+ onClick={() => {
112
+ setOpen(false)
113
+ action.onSelect(contact)
114
+ }}
115
+ >
116
+ {action.icon ? <span className="bridge-network-helper__person-menu-icon">{action.icon}</span> : null}
117
+ <span>
118
+ <span className="bridge-network-helper__person-menu-label">{action.label}</span>
119
+ {action.description ? (
120
+ <span className="bridge-network-helper__person-menu-description">{action.description}</span>
121
+ ) : null}
122
+ </span>
123
+ </button>
124
+ ))}
125
+ {makeIntro ? (
126
+ <a className="bridge-network-helper__person-menu-item" role="menuitem" href={makeIntro} target="_blank" rel="noopener noreferrer">
127
+ <UserPlusIcon className="bridge-network-helper__person-menu-icon" />
128
+ Make intro
129
+ </a>
130
+ ) : null}
131
+ {contact.shareLink ? (
132
+ <button
133
+ type="button"
134
+ role="menuitem"
135
+ className="bridge-network-helper__person-menu-item"
136
+ onClick={() => {
137
+ if (!copied) void copyIntroLink()
138
+ }}
139
+ >
140
+ {copied ? (
141
+ <CheckIcon className="bridge-network-helper__person-menu-icon is-copied" />
142
+ ) : (
143
+ <LinkIcon className="bridge-network-helper__person-menu-icon" />
144
+ )}
145
+ {copied ? "Copied" : "Copy intro link"}
146
+ </button>
147
+ ) : null}
148
+ {profileUrl ? (
149
+ <a className="bridge-network-helper__person-menu-item" role="menuitem" href={profileUrl} target="_blank" rel="noopener noreferrer">
150
+ <UserIcon className="bridge-network-helper__person-menu-icon" />
151
+ View Bridge profile
152
+ </a>
153
+ ) : null}
154
+ </div>
155
+ ) : null
156
+
157
+ return (
158
+ <>
159
+ <button
160
+ ref={buttonRef}
161
+ type="button"
162
+ className="bridge-network-helper__person-menu"
163
+ aria-label={`More options for ${contact.name}`}
164
+ aria-haspopup="menu"
165
+ aria-expanded={open}
166
+ data-state={open ? "open" : "closed"}
167
+ onClick={(event) => {
168
+ event.stopPropagation()
169
+ setOpen((current) => !current)
170
+ setCopied(false)
171
+ }}
172
+ >
173
+ <MoreIcon />
174
+ </button>
175
+ {menu && typeof document !== "undefined" ? createPortal(menu, document.body) : null}
176
+ </>
177
+ )
178
+ }
179
+
180
+ export function ShareLinkCopyControl({ shareLink }: { shareLink: string }) {
181
+ const [copied, setCopied] = useState(false)
182
+ const display = shareLink.replace(/^https?:\/\//i, "")
183
+
184
+ async function handleCopy() {
185
+ try {
186
+ await navigator.clipboard.writeText(shareLink)
187
+ setCopied(true)
188
+ window.setTimeout(() => setCopied(false), 1600)
189
+ } catch {
190
+ // Clipboard can fail in restricted contexts.
191
+ }
192
+ }
193
+
194
+ return (
195
+ <button
196
+ type="button"
197
+ className="bridge-network-helper__share-link"
198
+ onClick={handleCopy}
199
+ title={copied ? "Copied" : "Copy Introlink"}
200
+ aria-label={copied ? "Introlink copied" : `Copy Introlink ${display}`}
201
+ >
202
+ <span className="bridge-network-helper__share-link-text">{display}</span>
203
+ {copied ? (
204
+ <CheckIcon className="bridge-network-helper__share-link-icon is-copied" />
205
+ ) : (
206
+ <CopyIcon className="bridge-network-helper__share-link-icon" />
207
+ )}
208
+ </button>
209
+ )
210
+ }
@@ -0,0 +1,53 @@
1
+ "use client"
2
+
3
+ import { createContext, useContext, useMemo, useRef, type ReactNode } from "react"
4
+ import type { BridgeClientConfig } from "./types"
5
+
6
+ /** Production Bridge API, including the `/api/v4` prefix. */
7
+ export const DEFAULT_API_BASE_URL = "https://api.brdg.app/api/v4"
8
+
9
+ const DEFAULT_INTRO_INBOX_URL = "https://intros.brdg.app/inbox"
10
+
11
+ function defaultProfileUrl(username: string): string {
12
+ return `https://os.brdg.app/to/${encodeURIComponent(username.trim())}`
13
+ }
14
+
15
+ const BridgeUIContext = createContext<BridgeClientConfig | null>(null)
16
+
17
+ export type BridgeUIProviderProps = {
18
+ /** Override the production API (`https://api.brdg.app/api/v4`) for staging or local. */
19
+ apiBaseUrl?: string
20
+ getAccessToken: () => Promise<string | null> | string | null
21
+ /** Inbox used by the built-in Make intro action. */
22
+ introInboxUrl?: string
23
+ /** Profile URL used by the built-in View Bridge profile action. */
24
+ profileUrlForUsername?: (username: string) => string
25
+ children: ReactNode
26
+ }
27
+
28
+ export function BridgeUIProvider({
29
+ apiBaseUrl = DEFAULT_API_BASE_URL,
30
+ getAccessToken,
31
+ introInboxUrl = DEFAULT_INTRO_INBOX_URL,
32
+ profileUrlForUsername = defaultProfileUrl,
33
+ children,
34
+ }: BridgeUIProviderProps) {
35
+ const getAccessTokenRef = useRef(getAccessToken)
36
+ getAccessTokenRef.current = getAccessToken
37
+
38
+ const value = useMemo<BridgeClientConfig>(
39
+ () => ({
40
+ apiBaseUrl: apiBaseUrl.replace(/\/$/, ""),
41
+ getAccessToken: () => getAccessTokenRef.current(),
42
+ introInboxUrl,
43
+ profileUrlForUsername,
44
+ }),
45
+ [apiBaseUrl, introInboxUrl, profileUrlForUsername],
46
+ )
47
+
48
+ return <BridgeUIContext.Provider value={value}>{children}</BridgeUIContext.Provider>
49
+ }
50
+
51
+ export function useBridgeUI(): BridgeClientConfig | null {
52
+ return useContext(BridgeUIContext)
53
+ }