Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

add typeahead

Changed files
+298 -11
public
+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
··· 24 24 "@radix-ui/react-slot": "^1.2.3", 25 25 "@radix-ui/react-tabs": "^1.1.13", 26 26 "@tanstack/react-query": "^5.90.2", 27 + "actor-typeahead": "^0.1.1", 27 28 "atproto-ui": "^0.11.1", 28 29 "class-variance-authority": "^0.7.1", 29 30 "clsx": "^2.1.1",
+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"