-
Notifications
You must be signed in to change notification settings - Fork 38
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ Generic RepoFinder component (#144)
* ✨ Add typeahead search in cmd palette * ✨ Add a generic RepoFinder input field that searches by handle/did and shows profile details * ✨ Bring back missing props for input field * 🧹 use DID instead of handle for key
- Loading branch information
Showing
3 changed files
with
170 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,150 @@ | ||
import { useState, useEffect } from 'react' | ||
import { Combobox } from '@headlessui/react' | ||
import client from '@/lib/client' | ||
import { classNames } from '@/lib/util' | ||
|
||
type TypeaheadResult = { | ||
did: string | ||
handle: string | ||
avatar?: string | ||
displayName?: string | ||
} | ||
|
||
type RepoFinderProps = { | ||
selectionType?: 'did' | 'handle' | ||
onChange: (value: string) => void | ||
inputProps?: React.InputHTMLAttributes<HTMLInputElement> | ||
} | ||
|
||
const DefaultResult = { | ||
did: '', | ||
handle: 'DID or Handle', | ||
displayName: 'Start typing to search...', | ||
} | ||
|
||
const ErrorResult = { | ||
did: '', | ||
handle: 'Error searching', | ||
displayName: 'Start typing to search...', | ||
} | ||
|
||
const getProfilesForQuery = async (q: string): Promise<TypeaheadResult[]> => { | ||
const headers = { headers: client.proxyHeaders() } | ||
if (q.startsWith('did:')) { | ||
const { data: profile } = await client.api.app.bsky.actor.getProfile( | ||
{ | ||
actor: q, | ||
}, | ||
headers, | ||
) | ||
|
||
return profile | ||
? [ | ||
{ | ||
displayName: profile.displayName, | ||
handle: profile.handle, | ||
avatar: profile.avatar, | ||
did: profile.did, | ||
}, | ||
] | ||
: [] | ||
} | ||
|
||
const { | ||
data: { actors }, | ||
} = await client.api.app.bsky.actor.searchActorsTypeahead({ q }, headers) | ||
|
||
return actors.map((actor) => ({ | ||
displayName: actor.displayName, | ||
handle: actor.handle, | ||
avatar: actor.avatar, | ||
did: actor.did, | ||
})) | ||
} | ||
|
||
export function RepoFinder({ | ||
selectionType = 'did', | ||
onChange, | ||
inputProps = {}, | ||
}: RepoFinderProps) { | ||
const [query, setQuery] = useState('') | ||
const [selectedItem, setSelectedItem] = useState<string>('') | ||
const [items, setItems] = useState<TypeaheadResult[]>([DefaultResult]) | ||
const [loading, setLoading] = useState(false) | ||
|
||
useEffect(() => { | ||
if (query.length > 0) { | ||
setLoading(true) | ||
getProfilesForQuery(query) | ||
.then((profiles) => { | ||
setItems(profiles) | ||
setLoading(false) | ||
}) | ||
.catch((error) => { | ||
console.error('Error fetching data:', error) | ||
setLoading(false) | ||
setItems([ErrorResult]) | ||
}) | ||
} else { | ||
setItems([DefaultResult]) | ||
} | ||
}, [query]) | ||
|
||
return ( | ||
<Combobox | ||
value={selectedItem} | ||
onChange={(item) => { | ||
setSelectedItem(item) | ||
onChange(item) | ||
}} | ||
> | ||
<Combobox.Input | ||
// This is intentionally spread on top so that any of the below props are passed via inputProps, they are ignored | ||
// This also helps with the classname overwrite | ||
{...inputProps} | ||
className={classNames( | ||
'rounded-md border-gray-300 dark:border-teal-500 dark:bg-slate-700 shadow-sm dark:shadow-slate-700 focus:border-indigo-500 focus:ring-indigo-500 dark:focus:ring-teal-500 sm:text-sm disabled:text-gray-500 dark:text-gray-100 disabled:dark:text-gray-300', | ||
inputProps.className, | ||
)} | ||
onChange={(e) => setQuery(e.target.value)} | ||
placeholder="Search..." | ||
/> | ||
<Combobox.Options className="rounded mt-1 max-h-60 overflow-y-auto bg-gray-100 dark:bg-slate-900 shadow-sm"> | ||
{loading ? ( | ||
<div className="p-2">Loading...</div> | ||
) : items.length === 0 && query !== '' ? ( | ||
<div className="p-2"> | ||
<div className="font-semibold text-sm">No results found</div> | ||
<div className="text-sm"> | ||
Queries must be partial handle or full DIDs | ||
</div> | ||
</div> | ||
) : ( | ||
items.map((item) => ( | ||
<Combobox.Option | ||
key={item.did} | ||
value={selectionType === 'did' ? item.did : item.handle} | ||
className={({ active }) => | ||
`cursor-pointer p-2 flex items-center space-x-3 ${ | ||
active ? 'bg-gray-400 text-white dark:bg-slate-700' : '' | ||
}` | ||
} | ||
> | ||
<img | ||
alt={item.displayName || item.handle} | ||
className="h-7 w-7 rounded-full" | ||
src={item.avatar || '/img/default-avatar.jpg'} | ||
/> | ||
<div> | ||
<div className="font-semibold text-sm">@{item.handle}</div> | ||
<div className="text-sm"> | ||
{item.displayName || 'No display name'} | ||
</div> | ||
</div> | ||
</Combobox.Option> | ||
)) | ||
)} | ||
</Combobox.Options> | ||
</Combobox> | ||
) | ||
} |