diff --git a/web/src/components/login-form.tsx b/web/src/components/login-form.tsx index a7ff01c..3e1bbff 100644 --- a/web/src/components/login-form.tsx +++ b/web/src/components/login-form.tsx @@ -1,17 +1,29 @@ "use client" import { useState } from "react" +import { XIcon } from "lucide-react" import { cn } from "@/lib/utils" import { useAuth } from "@/lib/auth-context" +import { + useHandleTypeahead, + type TypeaheadActor, +} from "@/hooks/use-handle-typeahead" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" +import { + Combobox, + ComboboxContent, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox" import { Field, FieldDescription, FieldGroup, FieldLabel, } from "@/components/ui/field" -import { Input } from "@/components/ui/input" export function LoginForm({ className, @@ -19,23 +31,37 @@ export function LoginForm({ ...props }: React.ComponentProps<"div"> & { externalError?: string | null }) { const [handle, setHandle] = useState("") + const [selectedActor, setSelectedActor] = useState( + null, + ) + const [dropdownOpen, setDropdownOpen] = useState(false) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const { login } = useAuth() + const { actors } = useHandleTypeahead(selectedActor ? "" : handle) + + const actorsByHandle = new Map(actors.map((a) => [a.handle, a])) + const showDropdown = dropdownOpen && actors.length > 0 async function handleSubmit(e: React.FormEvent) { e.preventDefault() - if (!handle.trim()) return + const value = selectedActor?.handle ?? handle + if (!value.trim()) return setLoading(true) setError(null) try { - await login(handle.trim().toLowerCase()) + await login(value.trim().toLowerCase()) } catch (e: unknown) { setError(e instanceof Error ? e.message : "Login failed") setLoading(false) } } + function clearSelection() { + setSelectedActor(null) + setHandle("") + } + return (
@@ -51,18 +77,112 @@ export function LoginForm({ )} Handle - setHandle(e.target.value)} - required - disabled={loading} - /> + {selectedActor ? ( +
+ + {selectedActor.avatar && ( + + )} + + {( + selectedActor.displayName?.[0] ?? + selectedActor.handle[0] + ).toUpperCase()} + + +
+ {selectedActor.displayName && ( + + {selectedActor.displayName} + + )} + + @{selectedActor.handle} + +
+ +
+ ) : ( + { + if (details.reason === "input-change") { + setHandle(value) + } + }} + onValueChange={(value) => { + if (value) { + const actor = actorsByHandle.get(value as string) + if (actor) { + setSelectedActor(actor) + setHandle(actor.handle) + } else { + setHandle(value as string) + } + } + }} + open={showDropdown} + onOpenChange={setDropdownOpen} + items={actors.map((a) => a.handle)} + filter={null} + > + + + + {(item: string) => { + const actor = actorsByHandle.get(item) + return ( + + + {actor?.avatar && ( + + )} + + {( + actor?.displayName?.[0] ?? item[0] + ).toUpperCase()} + + +
+ {actor?.displayName && ( + + {actor.displayName} + + )} + + @{item} + +
+
+ ) + }} +
+
+
+ )}
- diff --git a/web/src/components/ui/combobox.tsx b/web/src/components/ui/combobox.tsx index 03f3e52..52ccbf3 100644 --- a/web/src/components/ui/combobox.tsx +++ b/web/src/components/ui/combobox.tsx @@ -69,21 +69,23 @@ function ComboboxInput({ render={} {...props} /> - - {showTrigger && ( - - - - )} - {showClear && } - + {(showTrigger || showClear) && ( + + {showTrigger && ( + + + + )} + {showClear && } + + )} {children} ) diff --git a/web/src/hooks/use-handle-typeahead.ts b/web/src/hooks/use-handle-typeahead.ts new file mode 100644 index 0000000..232f2f7 --- /dev/null +++ b/web/src/hooks/use-handle-typeahead.ts @@ -0,0 +1,49 @@ +import { useEffect, useRef, useState } from "react" + +export interface TypeaheadActor { + did: string + handle: string + displayName?: string + avatar?: string +} + +export function useHandleTypeahead(query: string, delay = 200) { + const [actors, setActors] = useState([]) + const abortRef = useRef(null) + + useEffect(() => { + if (query.length < 2) { + setActors([]) + return + } + + abortRef.current?.abort() + + const timeout = setTimeout(() => { + const controller = new AbortController() + abortRef.current = controller + + fetch( + `https://typeahead.waow.tech/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=8`, + { + signal: controller.signal, + headers: { "X-Client": "happyview" }, + }, + ) + .then((res) => (res.ok ? res.json() : Promise.reject())) + .then((data) => setActors(data.actors ?? [])) + .catch((e) => { + if (!(e instanceof DOMException && e.name === "AbortError")) { + setActors([]) + } + }) + }, delay) + + return () => { + clearTimeout(timeout) + abortRef.current?.abort() + } + }, [query, delay]) + + return { actors } +}