+3
bun.lock
+3
bun.lock
···
20
20
"@radix-ui/react-slot": "^1.2.3",
21
21
"@radix-ui/react-tabs": "^1.1.13",
22
22
"@tanstack/react-query": "^5.90.2",
23
+
"actor-typeahead": "^0.1.1",
23
24
"atproto-ui": "^0.11.1",
24
25
"class-variance-authority": "^0.7.1",
25
26
"clsx": "^2.1.1",
···
387
388
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
388
389
389
390
"acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="],
391
+
392
+
"actor-typeahead": ["actor-typeahead@0.1.1", "", {}, "sha512-ilsBwzplKwMSBiO6Tg6RdaZ5xxqgXds5jCQuHV+ib9Aq3ja9g0T7u2Y1PmihotmS7l5RxhpGI/tPm3ljoRDRwg=="],
390
393
391
394
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
392
395
+1
package.json
+1
package.json
+294
-11
public/index.tsx
+294
-11
public/index.tsx
···
1
-
import { useState, useRef, useEffect } from 'react'
1
+
import React, { useState, useRef, useEffect } from 'react'
2
2
import { createRoot } from 'react-dom/client'
3
3
import {
4
4
ArrowRight,
···
9
9
Code,
10
10
Server
11
11
} from 'lucide-react'
12
-
13
12
import Layout from '@public/layouts'
14
13
import { Button } from '@public/components/ui/button'
15
14
import { Card } from '@public/components/ui/card'
16
15
import { BlueskyPostList, BlueskyProfile, BlueskyPost, AtProtoProvider, useLatestRecord, type AtProtoStyles, type FeedPostRecord } from 'atproto-ui'
17
16
import 'atproto-ui/styles.css'
18
17
18
+
//Credit to https://tangled.org/@jakelazaroff.com/actor-typeahead
19
+
interface Actor {
20
+
handle: string
21
+
avatar?: string
22
+
displayName?: string
23
+
}
24
+
25
+
interface ActorTypeaheadProps {
26
+
children: React.ReactElement<React.InputHTMLAttributes<HTMLInputElement>>
27
+
host?: string
28
+
rows?: number
29
+
onSelect?: (handle: string) => void
30
+
autoSubmit?: boolean
31
+
}
32
+
33
+
const ActorTypeahead: React.FC<ActorTypeaheadProps> = ({
34
+
children,
35
+
host = 'https://public.api.bsky.app',
36
+
rows = 5,
37
+
onSelect,
38
+
autoSubmit = false
39
+
}) => {
40
+
const [actors, setActors] = useState<Actor[]>([])
41
+
const [index, setIndex] = useState(-1)
42
+
const [pressed, setPressed] = useState(false)
43
+
const [isOpen, setIsOpen] = useState(false)
44
+
const containerRef = useRef<HTMLDivElement>(null)
45
+
const inputRef = useRef<HTMLInputElement>(null)
46
+
const lastQueryRef = useRef<string>('')
47
+
const previousValueRef = useRef<string>('')
48
+
const preserveIndexRef = useRef(false)
49
+
50
+
const handleInput = async (e: React.FormEvent<HTMLInputElement>) => {
51
+
const query = e.currentTarget.value
52
+
53
+
// Check if the value actually changed (filter out arrow key events)
54
+
if (query === previousValueRef.current) {
55
+
return
56
+
}
57
+
previousValueRef.current = query
58
+
59
+
if (!query) {
60
+
setActors([])
61
+
setIndex(-1)
62
+
setIsOpen(false)
63
+
lastQueryRef.current = ''
64
+
return
65
+
}
66
+
67
+
// Store the query for this request
68
+
const currentQuery = query
69
+
lastQueryRef.current = currentQuery
70
+
71
+
try {
72
+
const url = new URL('xrpc/app.bsky.actor.searchActorsTypeahead', host)
73
+
url.searchParams.set('q', query)
74
+
url.searchParams.set('limit', `${rows}`)
75
+
76
+
const res = await fetch(url)
77
+
const json = await res.json()
78
+
79
+
// Only update if this is still the latest query
80
+
if (lastQueryRef.current === currentQuery) {
81
+
setActors(json.actors || [])
82
+
// Only reset index if we're not preserving it
83
+
if (!preserveIndexRef.current) {
84
+
setIndex(-1)
85
+
}
86
+
preserveIndexRef.current = false
87
+
setIsOpen(true)
88
+
}
89
+
} catch (error) {
90
+
console.error('Failed to fetch actors:', error)
91
+
if (lastQueryRef.current === currentQuery) {
92
+
setActors([])
93
+
setIsOpen(false)
94
+
}
95
+
}
96
+
}
97
+
98
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
99
+
const navigationKeys = ['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp', 'Enter', 'Escape']
100
+
101
+
// Mark that we should preserve the index for navigation keys
102
+
if (navigationKeys.includes(e.key)) {
103
+
preserveIndexRef.current = true
104
+
}
105
+
106
+
if (!isOpen || actors.length === 0) return
107
+
108
+
switch (e.key) {
109
+
case 'ArrowDown':
110
+
e.preventDefault()
111
+
setIndex((prev) => {
112
+
const newIndex = prev < 0 ? 0 : Math.min(prev + 1, actors.length - 1)
113
+
return newIndex
114
+
})
115
+
break
116
+
case 'PageDown':
117
+
e.preventDefault()
118
+
setIndex(actors.length - 1)
119
+
break
120
+
case 'ArrowUp':
121
+
e.preventDefault()
122
+
setIndex((prev) => {
123
+
const newIndex = prev < 0 ? 0 : Math.max(prev - 1, 0)
124
+
return newIndex
125
+
})
126
+
break
127
+
case 'PageUp':
128
+
e.preventDefault()
129
+
setIndex(0)
130
+
break
131
+
case 'Escape':
132
+
e.preventDefault()
133
+
setActors([])
134
+
setIndex(-1)
135
+
setIsOpen(false)
136
+
break
137
+
case 'Enter':
138
+
if (index >= 0 && index < actors.length) {
139
+
e.preventDefault()
140
+
selectActor(actors[index].handle)
141
+
}
142
+
break
143
+
}
144
+
}
145
+
146
+
const selectActor = (handle: string) => {
147
+
if (inputRef.current) {
148
+
inputRef.current.value = handle
149
+
}
150
+
setActors([])
151
+
setIndex(-1)
152
+
setIsOpen(false)
153
+
onSelect?.(handle)
154
+
155
+
// Auto-submit the form if enabled
156
+
if (autoSubmit && inputRef.current) {
157
+
const form = inputRef.current.closest('form')
158
+
if (form) {
159
+
// Use setTimeout to ensure the value is set before submission
160
+
setTimeout(() => {
161
+
form.requestSubmit()
162
+
}, 0)
163
+
}
164
+
}
165
+
}
166
+
167
+
const handleFocusOut = (e: React.FocusEvent) => {
168
+
if (pressed) return
169
+
setActors([])
170
+
setIndex(-1)
171
+
setIsOpen(false)
172
+
}
173
+
174
+
// Clone the input element and add our event handlers
175
+
const input = React.cloneElement(children, {
176
+
ref: (el: HTMLInputElement) => {
177
+
inputRef.current = el
178
+
// Preserve the original ref if it exists
179
+
const originalRef = (children as any).ref
180
+
if (typeof originalRef === 'function') {
181
+
originalRef(el)
182
+
} else if (originalRef) {
183
+
originalRef.current = el
184
+
}
185
+
},
186
+
onInput: (e: React.FormEvent<HTMLInputElement>) => {
187
+
handleInput(e)
188
+
children.props.onInput?.(e)
189
+
},
190
+
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
191
+
handleKeyDown(e)
192
+
children.props.onKeyDown?.(e)
193
+
},
194
+
onBlur: (e: React.FocusEvent<HTMLInputElement>) => {
195
+
handleFocusOut(e)
196
+
children.props.onBlur?.(e)
197
+
},
198
+
autoComplete: 'off'
199
+
} as any)
200
+
201
+
return (
202
+
<div ref={containerRef} style={{ position: 'relative', display: 'block' }}>
203
+
{input}
204
+
{isOpen && actors.length > 0 && (
205
+
<ul
206
+
style={{
207
+
display: 'flex',
208
+
flexDirection: 'column',
209
+
position: 'absolute',
210
+
left: 0,
211
+
marginTop: '4px',
212
+
width: '100%',
213
+
listStyle: 'none',
214
+
overflow: 'hidden',
215
+
backgroundColor: 'rgba(255, 255, 255, 0.7)',
216
+
backgroundClip: 'padding-box',
217
+
backdropFilter: 'blur(12px)',
218
+
WebkitBackdropFilter: 'blur(12px)',
219
+
border: '1px solid hsl(var(--border))',
220
+
borderRadius: '8px',
221
+
boxShadow: '0 6px 6px -4px rgba(0, 0, 0, 0.2)',
222
+
padding: '4px',
223
+
margin: 0,
224
+
zIndex: 1000
225
+
}}
226
+
onMouseDown={() => setPressed(true)}
227
+
onMouseUp={() => {
228
+
setPressed(false)
229
+
inputRef.current?.focus()
230
+
}}
231
+
>
232
+
{actors.map((actor, i) => (
233
+
<li key={actor.handle}>
234
+
<button
235
+
type="button"
236
+
onClick={() => selectActor(actor.handle)}
237
+
style={{
238
+
all: 'unset',
239
+
boxSizing: 'border-box',
240
+
display: 'flex',
241
+
alignItems: 'center',
242
+
gap: '8px',
243
+
padding: '6px 8px',
244
+
width: '100%',
245
+
height: 'calc(1.5rem + 12px)',
246
+
borderRadius: '4px',
247
+
cursor: 'pointer',
248
+
backgroundColor: i === index ? 'hsl(var(--accent) / 0.5)' : 'transparent',
249
+
transition: 'background-color 0.1s'
250
+
}}
251
+
onMouseEnter={() => setIndex(i)}
252
+
>
253
+
<div
254
+
style={{
255
+
width: '1.5rem',
256
+
height: '1.5rem',
257
+
borderRadius: '50%',
258
+
backgroundColor: 'hsl(var(--muted))',
259
+
overflow: 'hidden',
260
+
flexShrink: 0
261
+
}}
262
+
>
263
+
{actor.avatar && (
264
+
<img
265
+
src={actor.avatar}
266
+
alt=""
267
+
style={{
268
+
display: 'block',
269
+
width: '100%',
270
+
height: '100%',
271
+
objectFit: 'cover'
272
+
}}
273
+
/>
274
+
)}
275
+
</div>
276
+
<span
277
+
style={{
278
+
whiteSpace: 'nowrap',
279
+
overflow: 'hidden',
280
+
textOverflow: 'ellipsis',
281
+
color: 'hsl(var(--foreground))'
282
+
}}
283
+
>
284
+
{actor.handle}
285
+
</span>
286
+
</button>
287
+
</li>
288
+
))}
289
+
</ul>
290
+
)}
291
+
</div>
292
+
)
293
+
}
294
+
19
295
const LatestPostWithPrefetch: React.FC<{ did: string }> = ({ did }) => {
20
-
// Fetch once with the hook
21
296
const { record, rkey, loading } = useLatestRecord<FeedPostRecord>(
22
297
did,
23
298
'app.bsky.feed.post'
···
26
301
if (loading) return <span>Loading…</span>
27
302
if (!record || !rkey) return <span>No posts yet.</span>
28
303
29
-
// Pass prefetched record—BlueskyPost won't re-fetch it
30
304
return <BlueskyPost did={did} rkey={rkey} record={record} showParent={true} />
31
305
}
32
306
···
187
461
}}
188
462
className="space-y-3"
189
463
>
190
-
<input
191
-
ref={inputRef}
192
-
type="text"
193
-
name="handle"
194
-
placeholder="Enter your handle (e.g., alice.bsky.social)"
195
-
className="w-full py-4 px-4 text-lg bg-input border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent"
196
-
/>
464
+
<ActorTypeahead
465
+
autoSubmit={true}
466
+
onSelect={(handle) => {
467
+
if (inputRef.current) {
468
+
inputRef.current.value = handle
469
+
}
470
+
}}
471
+
>
472
+
<input
473
+
ref={inputRef}
474
+
type="text"
475
+
name="handle"
476
+
placeholder="Enter your handle (e.g., alice.bsky.social)"
477
+
className="w-full py-4 px-4 text-lg bg-input border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent"
478
+
/>
479
+
</ActorTypeahead>
197
480
<button
198
481
type="submit"
199
482
className="w-full bg-accent hover:bg-accent/90 text-accent-foreground font-semibold py-4 px-6 text-lg rounded-lg inline-flex items-center justify-center transition-colors"