Debounced search bar — queries update 300ms after typing stops, clears with ✕ button
Repositories tab — lists all public repos as cards with owner/name link, description, default branch chip, last-updated time; sort by recently updated / newest / name A–Z; prev/next pagination Users tab — grid of user cards with avatar/initials, username, join date; pagination Skeleton loaders while fetching, opacity fade during page transitions All state (tab, sort, query) reflected in the URL so links are shareable
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
import { api } from '../client'
|
||||
|
||||
const exploreRepoSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
isPrivate: z.boolean(),
|
||||
defaultBranch: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
ownerId: z.number(),
|
||||
ownerUsername: z.string(),
|
||||
ownerAvatarUrl: z.string(),
|
||||
})
|
||||
const exploreReposSchema = z.array(exploreRepoSchema)
|
||||
export type ExploreRepo = z.infer<typeof exploreRepoSchema>
|
||||
|
||||
const exploreUserSchema = z.object({
|
||||
id: z.number(),
|
||||
username: z.string(),
|
||||
avatarUrl: z.string(),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
const exploreUsersSchema = z.array(exploreUserSchema)
|
||||
export type ExploreUser = z.infer<typeof exploreUserSchema>
|
||||
|
||||
export function useExploreRepos(q: string, sort: string, offset: number) {
|
||||
const params = new URLSearchParams({ q, sort, limit: '20', offset: String(offset) })
|
||||
return useQuery({
|
||||
queryKey: ['explore', 'repos', q, sort, offset],
|
||||
queryFn: () =>
|
||||
api.get<ExploreRepo[]>(`/api/v1/explore/repos?${params}`, exploreReposSchema),
|
||||
placeholderData: prev => prev,
|
||||
})
|
||||
}
|
||||
|
||||
export function useExploreUsers(q: string, offset: number) {
|
||||
const params = new URLSearchParams({ q, limit: '20', offset: String(offset) })
|
||||
return useQuery({
|
||||
queryKey: ['explore', 'users', q, offset],
|
||||
queryFn: () =>
|
||||
api.get<ExploreUser[]>(`/api/v1/explore/users?${params}`, exploreUsersSchema),
|
||||
placeholderData: prev => prev,
|
||||
})
|
||||
}
|
||||
@@ -1,18 +1,306 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { useExploreRepos, useExploreUsers } from '../api/queries/explore'
|
||||
import type { ExploreRepo, ExploreUser } from '../api/queries/explore'
|
||||
import { Skeleton } from '../ui/Skeleton'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
function hashColor(s: string): string {
|
||||
let h = 0
|
||||
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0
|
||||
const hue = Math.abs(h) % 360
|
||||
return `hsl(${hue},55%,45%)`
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const min = Math.floor(diff / 60000)
|
||||
if (min < 60) return `${min}m ago`
|
||||
const hr = Math.floor(min / 60)
|
||||
if (hr < 24) return `${hr}h ago`
|
||||
const d = Math.floor(hr / 24)
|
||||
if (d < 30) return `${d}d ago`
|
||||
const mo = Math.floor(d / 30)
|
||||
if (mo < 12) return `${mo}mo ago`
|
||||
return `${Math.floor(mo / 12)}y ago`
|
||||
}
|
||||
|
||||
export default function ExplorePage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const tab = (searchParams.get('tab') ?? 'repos') as 'repos' | 'users'
|
||||
const sort = searchParams.get('sort') ?? 'updated'
|
||||
const [inputValue, setInputValue] = useState(searchParams.get('q') ?? '')
|
||||
const [q, setQ] = useState(searchParams.get('q') ?? '')
|
||||
const [offset, setOffset] = useState(0)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Debounce search input
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setQ(inputValue)
|
||||
setOffset(0)
|
||||
setSearchParams(prev => {
|
||||
const next = new URLSearchParams(prev)
|
||||
if (inputValue) next.set('q', inputValue)
|
||||
else next.delete('q')
|
||||
next.delete('offset')
|
||||
return next
|
||||
}, { replace: true })
|
||||
}, 300)
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||
}, [inputValue])
|
||||
|
||||
function setTab(t: string) {
|
||||
setOffset(0)
|
||||
setSearchParams(prev => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.set('tab', t)
|
||||
next.delete('offset')
|
||||
return next
|
||||
}, { replace: true })
|
||||
}
|
||||
|
||||
function setSort(s: string) {
|
||||
setOffset(0)
|
||||
setSearchParams(prev => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.set('sort', s)
|
||||
next.delete('offset')
|
||||
return next
|
||||
}, { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6 space-y-6">
|
||||
<h1 className="text-xl font-semibold text-[var(--c-text)]">Explore</h1>
|
||||
<div className="flex flex-col items-center justify-center py-16 border border-dashed border-[var(--c-border)] rounded text-center gap-3">
|
||||
<svg width="40" height="40" fill="none" stroke="var(--c-subtle)" strokeWidth="1" viewBox="0 0 24 24">
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6 space-y-5">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-[var(--c-text)]">Explore</h1>
|
||||
<p className="text-sm text-[var(--c-muted)] mt-0.5">Discover public repositories and users.</p>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--c-muted)] pointer-events-none" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--c-text)]">Explore public repositories</p>
|
||||
<p className="text-xs text-[var(--c-muted)] mt-1">
|
||||
Federated discovery across ForgeBucket instances — coming soon.
|
||||
</p>
|
||||
<input
|
||||
value={inputValue}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
placeholder={tab === 'repos' ? 'Search repositories…' : 'Search users…'}
|
||||
className="w-full border border-[var(--c-border)] rounded-lg pl-10 pr-4 py-2.5 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)] transition-colors"
|
||||
/>
|
||||
{inputValue && (
|
||||
<button onClick={() => setInputValue('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--c-muted)] hover:text-[var(--c-text)]">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs + sort */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex border-b border-[var(--c-border)] w-full">
|
||||
<div className="flex gap-0 flex-1">
|
||||
{(['repos', 'users'] as const).map(t => (
|
||||
<button key={t} onClick={() => setTab(t)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors capitalize ${tab === t ? 'border-[var(--c-brand)] text-[var(--c-brand)]' : 'border-transparent text-[var(--c-muted)] hover:text-[var(--c-text)]'}`}>
|
||||
{t === 'repos' ? 'Repositories' : 'Users'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'repos' && (
|
||||
<div className="flex items-center gap-1 pb-1 shrink-0">
|
||||
<span className="text-xs text-[var(--c-muted)]">Sort:</span>
|
||||
<select value={sort} onChange={e => setSort(e.target.value)}
|
||||
className="text-xs border border-[var(--c-border)] rounded px-2 py-1 bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]">
|
||||
<option value="updated">Recently updated</option>
|
||||
<option value="created">Newest</option>
|
||||
<option value="name">Name A–Z</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{tab === 'repos'
|
||||
? <RepoResults q={q} sort={sort} offset={offset} setOffset={setOffset} />
|
||||
: <UserResults q={q} offset={offset} setOffset={setOffset} />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RepoResults({ q, sort, offset, setOffset }: { q: string; sort: string; offset: number; setOffset: (n: number) => void }) {
|
||||
const { data: repos = [], isLoading, isFetching } = useExploreRepos(q, sort, offset)
|
||||
|
||||
if (isLoading) return <RepoSkeleton />
|
||||
|
||||
if (repos.length === 0 && !isFetching) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<svg width="36" height="36" fill="none" stroke="var(--c-border)" strokeWidth="1.2" viewBox="0 0 24 24" className="mb-3">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v8.25A2.25 2.25 0 0 0 4.5 16.5h15a2.25 2.25 0 0 0 2.25-2.25V9A2.25 2.25 0 0 0 19.5 6.75h-1.06l-2.44-2.44a1.5 1.5 0 0 0-1.061-.44H8.69Z" />
|
||||
</svg>
|
||||
<p className="text-sm font-medium text-[var(--c-text)]">{q ? `No repositories match "${q}"` : 'No public repositories yet'}</p>
|
||||
<p className="text-xs text-[var(--c-muted)] mt-1">Try a different search term.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className={`divide-y divide-[var(--c-border)] border border-[var(--c-border)] rounded-lg overflow-hidden transition-opacity ${isFetching ? 'opacity-60' : ''}`}>
|
||||
{repos.map(repo => <RepoCard key={repo.id} repo={repo} />)}
|
||||
</ul>
|
||||
<Pagination offset={offset} count={repos.length} pageSize={PAGE_SIZE} onPrev={() => setOffset(Math.max(0, offset - PAGE_SIZE))} onNext={() => setOffset(offset + PAGE_SIZE)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RepoCard({ repo }: { repo: ExploreRepo }) {
|
||||
const color = hashColor(repo.ownerUsername + repo.name)
|
||||
return (
|
||||
<li className="flex items-start gap-4 px-4 py-4 bg-[var(--c-surface)] hover:bg-[var(--c-surface-muted)] transition-colors">
|
||||
<div className="shrink-0 w-9 h-9 rounded-md flex items-center justify-center text-white text-sm font-bold" style={{ backgroundColor: color }}>
|
||||
{repo.name.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Link to={`/repos/${repo.ownerUsername}/${repo.name}`}
|
||||
className="text-sm font-semibold text-[var(--c-brand)] hover:underline truncate">
|
||||
{repo.ownerUsername}/{repo.name}
|
||||
</Link>
|
||||
{repo.isPrivate && (
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider border border-[var(--c-border)] text-[var(--c-muted)] px-1.5 py-0.5 rounded-full">Private</span>
|
||||
)}
|
||||
</div>
|
||||
{repo.description && (
|
||||
<p className="text-xs text-[var(--c-muted)] mt-0.5 line-clamp-2">{repo.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
<span className="flex items-center gap-1 text-[10px] text-[var(--c-muted)]">
|
||||
<svg width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6h.008v.008H6V6Z" />
|
||||
</svg>
|
||||
{repo.defaultBranch}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--c-muted)]">Updated {timeAgo(repo.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function UserResults({ q, offset, setOffset }: { q: string; offset: number; setOffset: (n: number) => void }) {
|
||||
const { data: users = [], isLoading, isFetching } = useExploreUsers(q, offset)
|
||||
|
||||
if (isLoading) return <UserSkeleton />
|
||||
|
||||
if (users.length === 0 && !isFetching) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<svg width="36" height="36" fill="none" stroke="var(--c-border)" strokeWidth="1.2" viewBox="0 0 24 24" className="mb-3">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
<p className="text-sm font-medium text-[var(--c-text)]">{q ? `No users match "${q}"` : 'No users found'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className={`grid grid-cols-1 sm:grid-cols-2 gap-3 transition-opacity ${isFetching ? 'opacity-60' : ''}`}>
|
||||
{users.map(user => <UserCard key={user.id} user={user} />)}
|
||||
</ul>
|
||||
<Pagination offset={offset} count={users.length} pageSize={PAGE_SIZE} onPrev={() => setOffset(Math.max(0, offset - PAGE_SIZE))} onNext={() => setOffset(offset + PAGE_SIZE)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function UserCard({ user }: { user: ExploreUser }) {
|
||||
const color = hashColor(user.username)
|
||||
return (
|
||||
<li className="flex items-center gap-3 px-4 py-3 border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] hover:bg-[var(--c-surface-muted)] transition-colors">
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.username} className="w-10 h-10 rounded-full object-cover shrink-0" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-bold shrink-0" style={{ backgroundColor: color }}>
|
||||
{user.username.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<Link to={`/repos?owner=${user.username}`}
|
||||
className="text-sm font-semibold text-[var(--c-brand)] hover:underline block truncate">
|
||||
{user.username}
|
||||
</Link>
|
||||
<p className="text-[10px] text-[var(--c-muted)] mt-0.5">Joined {timeAgo(user.createdAt)}</p>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function Pagination({ offset, count, pageSize, onPrev, onNext }: {
|
||||
offset: number; count: number; pageSize: number; onPrev: () => void; onNext: () => void
|
||||
}) {
|
||||
const hasPrev = offset > 0
|
||||
const hasNext = count >= pageSize
|
||||
if (!hasPrev && !hasNext) return null
|
||||
const page = Math.floor(offset / pageSize) + 1
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button onClick={onPrev} disabled={!hasPrev}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-[var(--c-border)] rounded text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-xs text-[var(--c-muted)]">Page {page}</span>
|
||||
<button onClick={onNext} disabled={!hasNext}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-[var(--c-border)] rounded text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
Next
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RepoSkeleton() {
|
||||
return (
|
||||
<ul className="divide-y divide-[var(--c-border)] border border-[var(--c-border)] rounded-lg overflow-hidden">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<li key={i} className="flex items-start gap-4 px-4 py-4 bg-[var(--c-surface)]">
|
||||
<Skeleton className="w-9 h-9 rounded-md shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-40 rounded" />
|
||||
<Skeleton className="h-3 w-3/4 rounded" />
|
||||
<Skeleton className="h-3 w-24 rounded" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function UserSkeleton() {
|
||||
return (
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<li key={i} className="flex items-center gap-3 px-4 py-3 border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)]">
|
||||
<Skeleton className="w-10 h-10 rounded-full shrink-0" />
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Skeleton className="h-3.5 w-28 rounded" />
|
||||
<Skeleton className="h-2.5 w-20 rounded" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user