followgraph / components /Content.tsx
severo's picture
severo HF staff
not a huggingface member
42c475c
raw
history blame
17.3 kB
import { Spinner } from './Spinner'
import React, { useState, memo, useRef } from 'react'
import debounce from 'debounce'
// const usersCache = new Map<string, AccountDetails>()
type AccountDetails = {
user: string
fullname: string
avatarUrl: string
followed_by: Set<string> // list of usernames
followers_count: number
details: string
}
async function accountFollows(
handle: string,
limit: number,
logError: (x: string) => void
): Promise<Array<AccountDetails>> {
let nextPage:
| string
| null = `https://huggingface.co/api/users/${handle}/following`
let data: Array<AccountDetails> = []
while (nextPage && data.length <= limit) {
console.log(`Get page: ${nextPage}`)
let response
let page
try {
response = await fetch(nextPage)
if (response.status !== 200) {
throw new Error('HTTP request failed')
}
page = await response.json()
} catch (e) {
logError(`Error while retrieving follows for ${handle}.`)
break
}
if (!page.map) {
break
}
// const newData = await Promise.all(
// page.map(async (account) => {
// const user = account.user
// if (!usersCache.has(user)) {
// const details = await accountDetails(user, logError)
// // const followers_count = await accountFollowersCount(user, logError)
// usersCache.set(user, { ...account, details })
// }
// return usersCache.get(user)
// })
// )
// data = [...data, ...newData]
data = [...data, ...page]
nextPage = getNextPage(response.headers.get('Link'))
}
return data
}
async function organizationMembers(
organization: string,
logError: (x: string) => void
): Promise<Array<string>> {
let nextPage:
| string
| null = `https://huggingface.co/api/organizations/${organization}/members`
let members: Array<string> = []
while (nextPage) {
console.log(`Get page: ${nextPage}`)
let response
let page
try {
response = await fetch(nextPage)
if (response.status !== 200) {
throw new Error('HTTP request failed')
}
page = await response.json()
} catch (e) {
logError(`Error while retrieving members for ${organization}.`)
break
}
if (!page.map) {
break
}
members = [...members, ...page.map(({ user }) => user)]
nextPage = getNextPage(response.headers.get('Link'))
}
return members
}
// async function accountFollowersCount(
// handle: string,
// logError: (x: string) => void
// ): Promise<number> {
// let nextPage:
// | string
// | null = `https://huggingface.co/api/users/${handle}/followers`
// let count = 0
// while (nextPage) {
// console.log(`Get page: ${nextPage}`)
// let response
// let page
// try {
// response = await fetch(nextPage)
// if (response.status !== 200) {
// throw new Error('HTTP request failed')
// }
// page = await response.json()
// } catch (e) {
// logError(`Error while retrieving followers for ${handle}.`)
// break
// }
// if (!page.map) {
// break
// }
// count += page.length
// nextPage = getNextPage(response.headers.get('Link'))
// }
// return count
// }
// async function accountDetails(
// handle: string,
// logError: (x: string) => void
// ): Promise<string> {
// let page
// try {
// let response = await fetch(
// `https://huggingface.co/api/users/${handle}/overview`
// )
// if (response.status !== 200) {
// throw new Error('HTTP request failed')
// }
// let page = await response.json()
// return page?.details ?? ''
// } catch (e) {
// logError(`Error while retrieving details for ${handle}.`)
// }
// return ''
// }
async function accountFofs(
handle: string,
setProgress: (x: Array<number>) => void,
setFollows: (x: Array<AccountDetails>) => void,
logError: (x: string) => void
): Promise<void> {
const hfMembers = await organizationMembers('huggingface', logError)
const directFollows = await accountFollows(handle, 2000, logError)
setProgress([0, directFollows.length])
let progress = 0
const directFollowIds = new Set([
handle,
...directFollows.map(({ user }) => user),
...hfMembers,
])
const indirectFollowLists: Array<Array<AccountDetails>> = []
const updateList = debounce(() => {
let indirectFollows: Array<AccountDetails> = [].concat(
[],
...indirectFollowLists
)
const indirectFollowMap = new Map()
indirectFollows
.filter(
// exclude direct follows
({ user }) => !directFollowIds.has(user)
)
.map((account) => {
const acct = account.user
if (indirectFollowMap.has(acct)) {
const otherAccount = indirectFollowMap.get(acct)
account.followed_by = new Set([
...Array.from(account.followed_by.values()),
...otherAccount.followed_by,
])
}
indirectFollowMap.set(acct, account)
})
const list = Array.from(indirectFollowMap.values()).sort((a, b) => {
if (a.followed_by.size != b.followed_by.size) {
return b.followed_by.size - a.followed_by.size
}
return b.followers_count - a.followers_count
})
setFollows(list)
}, 2000)
await Promise.all(
directFollows.map(async ({ user }) => {
const follows = await accountFollows(user, 200, logError)
progress++
setProgress([progress, directFollows.length])
indirectFollowLists.push(
follows.map((account) => ({ ...account, followed_by: new Set([user]) }))
)
updateList()
})
)
updateList.flush()
}
function getNextPage(linkHeader: string | null): string | null {
if (!linkHeader) {
return null
}
// Example header:
// Link: <https://mastodon.example/api/v1/accounts/1/follows?limit=2&max_id=7628164>; rel="next", <https://mastodon.example/api/v1/accounts/1/follows?limit=2&since_id=7628165>; rel="prev"
const match = linkHeader.match(/<(.+)>; rel="next"/)
if (match && match.length > 0) {
return match[1]
}
return null
}
function matchesSearch(account: AccountDetails, search: string): boolean {
if (/^\s*$/.test(search)) {
return true
}
const sanitizedSearch = search.replace(/^\s+|\s+$/, '').toLocaleLowerCase()
if (account.user.toLocaleLowerCase().includes(sanitizedSearch)) {
return true
}
if (account.fullname.toLocaleLowerCase().includes(sanitizedSearch)) {
return true
}
return false
}
export function Content({}) {
const [handle, setHandle] = useState('')
const [follows, setFollows] = useState<Array<AccountDetails>>([])
const [isLoading, setLoading] = useState(false)
const [isDone, setDone] = useState(false)
const [[numLoaded, totalToLoad], setProgress] = useState<Array<number>>([
0, 0,
])
const [errors, setErrors] = useState<Array<string>>([])
async function search(handle: string) {
setErrors([])
setLoading(true)
setDone(false)
setFollows([])
setProgress([0, 0])
await accountFofs(handle, setProgress, setFollows, (error) =>
setErrors((e) => [...e, error])
)
setLoading(false)
setDone(true)
}
return (
<section className="bg-gray-50 dark:bg-gray-800" id="searchForm">
<div className="px-4 py-8 mx-auto space-y-12 lg:space-y-20 lg:py-24 max-w-screen-xl">
<form
onSubmit={(e) => {
search(handle)
e.preventDefault()
return false
}}
>
<div className="form-group mb-6 text-4xl lg:ml-16">
<label
htmlFor="huggingFaceHandle"
className="form-label inline-block mb-2 text-gray-700 dark:text-gray-200"
>
Your Hugging Face username:
</label>
<input
type="text"
value={handle}
onChange={(e) => setHandle(e.target.value)}
className="form-control
block
w-80
px-3
py-1.5
text-base
font-normal
text-gray-700
bg-white bg-clip-padding
border border-solid border-gray-300
rounded
transition
ease-in-out
m-0
focus:text-gray-900 focus:bg-white focus:border-green-600 focus:outline-none
dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-gray-200 dark:focus:bg-gray-900 dark:focus:text-gray-200
"
id="huggingFaceHandle"
aria-describedby="huggingFaceHandleHelp"
placeholder="merve"
/>
<button
type="submit"
className="
px-6
py-2.5
bg-green-600
text-white
font-medium
text-xs
leading-tight
uppercase
rounded
shadow-md
hover:bg-green-700 hover:shadow-lg
focus:bg-green-700 focus:shadow-lg focus:outline-none focus:ring-0
active:bg-green-800 active:shadow-lg
transition
duration-150
ease-in-out"
>
Search
<Spinner
visible={isLoading}
className="w-4 h-4 ml-2 fill-white"
/>
</button>
{isLoading ? (
<p className="text-sm dark:text-gray-400">
Loaded {numLoaded} of {totalToLoad}...
</p>
) : null}
{isDone && follows.length === 0 ? (
<div
className="flex p-4 mt-4 max-w-full sm:max-w-xl text-sm text-gray-700 bg-gray-100 rounded-lg dark:bg-gray-700 dark:text-gray-300"
role="alert"
>
<svg
aria-hidden="true"
className="flex-shrink-0 inline w-5 h-5 mr-3"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule="evenodd"
></path>
</svg>
<span className="sr-only">Info</span>
<div>
<span className="font-medium">No results found.</span> Please
double check for typos in the username, and ensure that you
follow at least a few people to seed the search. Otherwise,
try again later as Hugging Face may throttle requests.
</div>
</div>
) : null}
</div>
</form>
{isDone || follows.length > 0 ? <Results follows={follows} /> : null}
<ErrorLog errors={errors} />
</div>
</section>
)
}
const AccountDetails = memo(({ account }: { account: AccountDetails }) => {
const {
avatarUrl,
fullname,
user,
followed_by,
// followers_count,
// details
} = account
// let formatter = Intl.NumberFormat('en', { notation: 'compact' })
// let numFollowers = formatter.format(followers_count)
const [expandedFollowers, setExpandedFollowers] = useState(false)
const hasAvatar = avatarUrl && !avatarUrl.endsWith('.svg')
return (
<li className="px-4 py-3 pb-7 sm:px-0 sm:py-4">
<div className="flex flex-col gap-4 sm:flex-row">
<div className="flex-shrink-0 m-auto">
{/* eslint-disable-next-line @next/next/no-img-element */}
{hasAvatar ? (
<img
className="w-16 h-16 sm:w-8 sm:h-8 rounded-full"
src={avatarUrl}
/>
) : (
<div className="w-16 h-16 sm:w-8 sm:h-8" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate dark:text-white">
{fullname}
</p>
{/* <div className="flex flex-col sm:flex-row text-sm text-gray-500 dark:text-gray-400">
<span className="truncate">{user}</span>
<span className="sm:inline hidden whitespace-pre"> | </span>
<span>{numFollowers} followers</span>
</div> */}
{/* <br />
<small className="text-sm dark:text-gray-200">{details}</small> */}
<br />
<small className="text-xs text-gray-800 dark:text-gray-400">
Followed by{' '}
{followed_by.size < 9 || expandedFollowers ? (
Array.from<string>(followed_by.values()).map((handle, idx) => (
<React.Fragment key={handle}>
<span className="font-semibold">
{handle.replace(/@.+/, '')}
</span>
{idx === followed_by.size - 1 ? '.' : ', '}
</React.Fragment>
))
) : (
<>
<button
onClick={() => setExpandedFollowers(true)}
className="font-semibold"
>
{followed_by.size} of your contacts
</button>
.
</>
)}
</small>
</div>
<div className="inline-flex m-auto text-base font-semibold text-gray-900 dark:text-white">
<a
href={`https://huggingface.co/${user}`}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
target="_blank"
rel="noreferrer"
>
Follow
</a>
</div>
</div>
</li>
)
})
AccountDetails.displayName = 'AccountDetails'
function ErrorLog({ errors }: { errors: Array<string> }) {
const [expanded, setExpanded] = useState(false)
return (
<>
{errors.length > 0 ? (
<div className="text-sm text-gray-600 dark:text-gray-200 border border-solid border-gray-200 dark:border-gray-700 rounded p-4 max-w-4xl mx-auto">
Found{' '}
<button className="font-bold" onClick={() => setExpanded(!expanded)}>
{errors.length} warnings
</button>
{expanded ? ':' : '.'}
{expanded
? errors.map((err) => (
<p key={err} className="text-xs">
{err}
</p>
))
: null}
</div>
) : null}
</>
)
}
function Results({ follows }: { follows: Array<AccountDetails> }) {
let [search, setSearch] = useState<string>('')
const [isLoading, setLoading] = useState(false)
const updateSearch = useRef(
debounce((s: string) => {
setLoading(false)
setSearch(s)
}, 1500)
).current
follows = follows.filter((acc) => matchesSearch(acc, search)).slice(0, 500)
return (
<div className="flex-col lg:flex items-center justify-center">
<div className="max-w-4xl">
<div className="w-full mb-4 dark:text-gray-200">
<label>
<div className="mb-2">
<Spinner
visible={isLoading}
className="w-4 h-4 mr-1 fill-gray-400"
/>
Search:
</div>
<SearchInput
onChange={(s) => {
setLoading(true)
updateSearch(s)
}}
/>
</label>
</div>
<div className="content-center px-2 sm:px-8 py-4 bg-white border rounded-lg shadow-md dark:bg-gray-800 dark:border-gray-700">
<div className="flow-root">
{follows.length === 0 ? (
<p className="text-gray-700 dark:text-gray-200">
No results found.
</p>
) : null}
<ul
role="list"
className="divide-y divide-gray-200 dark:divide-gray-700"
>
{follows.map((account) => (
<AccountDetails key={account.user} account={account} />
))}
</ul>
</div>
</div>
</div>
</div>
)
}
function SearchInput({ onChange }: { onChange: (s: string) => void }) {
let [search, setSearchInputValue] = useState<string>('')
return (
<input
type="text"
placeholder="Schreiber"
value={search}
onChange={(e) => {
setSearchInputValue(e.target.value)
onChange(e.target.value)
}}
className="
form-control
block
w-80
px-3
py-1.5
text-base
font-normal
text-gray-700
bg-white bg-clip-padding
border border-solid border-gray-300
rounded
transition
ease-in-out
m-0
focus:text-gray-900 focus:bg-white focus:border-green-600 focus:outline-none
dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-gray-200 dark:focus:bg-gray-900 dark:focus:text-gray-200"
/>
)
}