diff --git a/src/App.tsx b/src/App.tsx index 6804abb..3d30371 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { IssuePage } from './pages/IssuePage' import { ProfilePage } from './pages/ProfilePage' import { PullPage } from './pages/PullPage' import { RepoPage } from './pages/RepoPage' +import { SearchPage } from './pages/SearchPage' import { StringPage } from './pages/StringPage' function App() { @@ -13,6 +14,7 @@ function App() { } /> + } /> } /> } /> } /> diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index ed40e8e..d060ac4 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,5 +1,5 @@ import { Link } from 'react-router-dom' -import { ActorSearch } from '../actor/ActorSearch' +import { RepoSearch } from '../repo/RepoSearch' import { PageContainer } from './PageContainer' export function Header() { @@ -14,7 +14,7 @@ export function Header() { snrld - + diff --git a/src/components/repo/RepoSearch.tsx b/src/components/repo/RepoSearch.tsx new file mode 100644 index 0000000..1c53e31 --- /dev/null +++ b/src/components/repo/RepoSearch.tsx @@ -0,0 +1,164 @@ +import { useEffect, useRef, useState } from 'react' +import { parseResourceUri } from '@atcute/lexicons' +import { isDid } from '@atcute/lexicons/syntax' +import { useNavigate } from 'react-router-dom' +import { useRepoSearch } from '../../hooks/useRepoSearch' +import { useVisibleActor } from '../../hooks/useVisibleActor' +import { resolveMiniDoc } from '../../lib/actor' +import { getRepoName, getRepoRkey, type Repo } from '../../lib/tangled' +import { usePointerDownOutside } from '../../hooks/usePointerDownOutside' +import { ProfileAvatar } from '../profile/ProfileAvatar' + +type RepoSearchProps = { + initialQuery?: string + size?: 'header' | 'page' +} + +const searchStyles = { + header: { + form: 'ml-auto max-w-sm border border-ctp-surface-1 bg-ctp-mantle', + input: 'px-3 py-1.5 text-sm', + button: 'px-3 py-1.5', + }, + page: { + form: 'max-w-xl border border-ctp-surface-1 bg-ctp-mantle', + input: 'px-4 py-3 text-base', + button: 'px-4 py-3', + }, +} + +export function RepoSearch({ initialQuery = '', size = 'page' }: RepoSearchProps) { + const [query, setQuery] = useState('') + const [isSuggestionsOpen, setIsSuggestionsOpen] = useState(false) + const [isOpening, setIsOpening] = useState(false) + const [openError, setOpenError] = useState(null) + const { results, isSearching, error } = useRepoSearch(query) + const searchFormRef = useRef(null) + const navigate = useNavigate() + const styles = searchStyles[size] + + usePointerDownOutside(searchFormRef, () => setIsSuggestionsOpen(false)) + + useEffect(() => setQuery(initialQuery), [initialQuery]) + + function submitSearch() { + const normalizedQuery = query.trim() + if (normalizedQuery.length === 0) return + + setIsSuggestionsOpen(false) + navigate(`/search?q=${encodeURIComponent(normalizedQuery)}`) + } + + async function selectRepo(repo: Repo) { + setIsOpening(true) + setOpenError(null) + try { + const owner = await resolveMiniDoc(parseResourceUri(repo.uri).repo) + setQuery('') + setIsSuggestionsOpen(false) + navigate(`/${owner.handle}/${encodeURIComponent(getRepoRkey(repo))}`) + } catch (error) { + setOpenError(error instanceof Error ? error : new Error('Could not open repository.')) + } finally { + setIsOpening(false) + } + } + + return ( +
{ + event.preventDefault() + submitSearch() + }} + > + { + setQuery(event.target.value) + setOpenError(null) + setIsSuggestionsOpen(true) + }} + className={`min-w-0 flex-1 bg-transparent font-mono text-ctp-text outline-none placeholder:text-ctp-overlay-0 ${styles.input}`} + /> + + + {isSuggestionsOpen && + (isSearching || isOpening || error || openError || results.length > 0) && ( +
+ {isSearching || isOpening ? ( +

+ {isOpening ? 'Opening…' : 'Searching…'} +

+ ) : error || openError ? ( +

+ {error?.message ?? openError?.message} +

+ ) : ( +
    + {results.map((repo) => ( + + ))} +
+ )} +
+ )} +
+ ) +} + +type RepoSearchResultProps = { + repo: Repo + onSelect: (repo: Repo) => Promise +} + +function RepoSearchResult({ repo, onSelect }: RepoSearchResultProps) { + const ownerIdentifier = parseResourceUri(repo.uri).repo + const { actor, elementRef } = useVisibleActor(isDid(ownerIdentifier) ? ownerIdentifier : null) + const ownerLabel = actor?.miniDoc.handle ?? ownerIdentifier + + return ( +
  • + +
  • + ) +} diff --git a/src/components/repo/RepoSearchResult.tsx b/src/components/repo/RepoSearchResult.tsx new file mode 100644 index 0000000..e3c5bb1 --- /dev/null +++ b/src/components/repo/RepoSearchResult.tsx @@ -0,0 +1,81 @@ +import type { Handle } from '@atcute/lexicons' +import { Link } from 'react-router-dom' +import Markdown from 'react-markdown' +import type { ResolvedActor } from '../../lib/actor' +import type { Repo } from '../../lib/tangled' +import { getRepoName, getRepoRkey } from '../../lib/tangled/repo' +import { ProfileAvatar } from '../profile/ProfileAvatar' +import { SurfaceCard } from '../shared/SurfaceCard' +import { RepoStarCount } from './RepoStarCount' + +const MAX_VISIBLE_TOPICS = 3 + +type RepoSearchResultProps = { + repo: Repo + handle: Handle | null + actor: ResolvedActor | null +} + +export function RepoSearchResult({ repo, handle, actor }: RepoSearchResultProps) { + const { value } = repo + const name = getRepoName(repo) + const rkey = getRepoRkey(repo) + + const content = ( + +
    +
    + {actor ? ( + + ) : handle ? ( + + {handle[0].toUpperCase()} + + ) : null} +

    + {handle && {handle}/} + {name} +

    +
    + +
    + + {value.description && ( +
    + {value.description} +
    + )} + + {value.topics && value.topics.length > 0 && ( +
      + {value.topics.slice(0, MAX_VISIBLE_TOPICS).map((topic) => ( +
    • + #{topic} +
    • + ))} +
    + )} +
    + ) + + if (handle === null) return content + + return ( + + {content} + + ) +} diff --git a/src/hooks/useVisibleActor.ts b/src/hooks/useVisibleActor.ts index 98d9de0..d6f9b2e 100644 --- a/src/hooks/useVisibleActor.ts +++ b/src/hooks/useVisibleActor.ts @@ -1,11 +1,12 @@ import type { Did } from '@atcute/lexicons' import { useEffect, useState } from 'react' -import { resolveActor, type ResolvedActor } from '../lib/actor' +import { resolveActor, resolveMiniDoc, type ResolvedActor } from '../lib/actor' import { useElementVisibility } from './useElementVisibility' type ActorState = { actor?: ResolvedActor did: Did + miniDoc?: ResolvedActor['miniDoc'] } export function useVisibleActor(did: Did | null) { @@ -17,14 +18,20 @@ export function useVisibleActor(did: Did | null) { let isCancelled = false - void resolveActor(did) - .then((actor) => { - if (!isCancelled) setState({ did, actor }) + void resolveMiniDoc(did) + .then((miniDoc) => { + if (!isCancelled) setState({ did, miniDoc }) }) .catch(() => { if (!isCancelled) setState({ did }) }) + void resolveActor(did) + .then((actor) => { + if (!isCancelled) setState({ did, actor, miniDoc: actor.miniDoc }) + }) + .catch(() => undefined) + return () => { isCancelled = true } @@ -32,6 +39,7 @@ export function useVisibleActor(did: Did | null) { return { actor: state?.did === did ? (state.actor ?? null) : null, + miniDoc: state?.did === did ? (state.miniDoc ?? null) : null, elementRef, isVisible, } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 5fb2c1e..7e22d31 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,4 +1,4 @@ -import { ActorSearch } from '../components/actor/ActorSearch' +import { RepoSearch } from '../components/repo/RepoSearch' import { PageContainer } from '../components/layout/PageContainer' export function HomePage() { @@ -10,7 +10,7 @@ export function HomePage() {

    An experimental Tangled client.

    snrld

    - +
    diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx new file mode 100644 index 0000000..bc9a11d --- /dev/null +++ b/src/pages/SearchPage.tsx @@ -0,0 +1,68 @@ +import { parseResourceUri } from '@atcute/lexicons' +import { isDid } from '@atcute/lexicons/syntax' +import { useSearchParams } from 'react-router-dom' +import { RepoSearch } from '../components/repo/RepoSearch' +import { RepoSearchResult } from '../components/repo/RepoSearchResult' +import { PageContainer } from '../components/layout/PageContainer' +import { useRepoSearch } from '../hooks/useRepoSearch' +import { useVisibleActor } from '../hooks/useVisibleActor' +import type { Repo } from '../lib/tangled' + +const RESULTS_LIMIT = 50 + +export function SearchPage() { + const [searchParams] = useSearchParams() + const query = searchParams.get('q')?.trim() ?? '' + const { results, isSearching, error } = useRepoSearch(query, { + limit: RESULTS_LIMIT, + minimumQueryLength: 1, + }) + + return ( +
    + +

    Search

    +
    + +
    + + {query.length === 0 ? ( +

    + Enter a search query to find repositories. +

    + ) : isSearching ? ( +

    + Searching… +

    + ) : error ? ( +

    + Could not search repositories: {error.message} +

    + ) : results.length === 0 ? ( +

    No repositories found for “{query}”.

    + ) : ( +
      + {results.map((repo) => ( + + ))} +
    + )} +
    +
    + ) +} + +type SearchResultProps = { repo: Repo } + +function SearchResult({ repo }: SearchResultProps) { + const ownerIdentifier = parseResourceUri(repo.uri).repo + const { actor, miniDoc, elementRef } = useVisibleActor( + isDid(ownerIdentifier) ? ownerIdentifier : null, + ) + + return ( +
  • + +
  • + ) +}