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:
2026-05-07 16:21:35 +02:00
parent 803672a610
commit b624337b4a
4 changed files with 475 additions and 9 deletions
+47
View File
@@ -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,
})
}
+296 -8
View File
@@ -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">
<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>
<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">
<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 AZ</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>
)
}
+127
View File
@@ -0,0 +1,127 @@
package handlers
import (
"net/http"
"strconv"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/models"
)
type ExploreHandler struct{ db *xorm.Engine }
func NewExploreHandler(db *xorm.Engine) *ExploreHandler { return &ExploreHandler{db: db} }
type exploreRepo struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
IsPrivate bool `json:"isPrivate"`
DefaultBranch string `json:"defaultBranch"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
OwnerID int64 `json:"ownerId"`
OwnerUsername string `json:"ownerUsername"`
OwnerAvatarURL string `json:"ownerAvatarUrl"`
}
type exploreUser struct {
ID int64 `json:"id"`
Username string `json:"username"`
AvatarURL string `json:"avatarUrl"`
CreatedAt string `json:"createdAt"`
}
func (h *ExploreHandler) Repos(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
sort := r.URL.Query().Get("sort")
limit := clampInt(r.URL.Query().Get("limit"), 20, 1, 50)
offset := clampInt(r.URL.Query().Get("offset"), 0, 0, 1<<30)
sess := h.db.Where("is_private = false")
if q != "" {
sess = sess.And("(name ILIKE ? OR description ILIKE ?)", "%"+q+"%", "%"+q+"%")
}
switch sort {
case "name":
sess = sess.Asc("name")
case "created":
sess = sess.Desc("created_at")
default:
sess = sess.Desc("updated_at")
}
var repos []models.Repository
if err := sess.Limit(limit, offset).Find(&repos); err != nil {
jsonError(w, "could not search repositories", http.StatusInternalServerError)
return
}
results := make([]exploreRepo, 0, len(repos))
for _, repo := range repos {
var owner models.User
found, _ := h.db.ID(repo.OwnerID).Cols("id", "username", "avatar_url").Get(&owner)
if !found {
continue
}
results = append(results, exploreRepo{
ID: repo.ID,
Name: repo.Name,
Description: repo.Description,
IsPrivate: repo.IsPrivate,
DefaultBranch: repo.DefaultBranch,
CreatedAt: repo.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: repo.UpdatedAt.Format("2006-01-02T15:04:05Z"),
OwnerID: owner.ID,
OwnerUsername: owner.Username,
OwnerAvatarURL: owner.AvatarURL,
})
}
jsonOK(w, results)
}
func (h *ExploreHandler) Users(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
limit := clampInt(r.URL.Query().Get("limit"), 20, 1, 50)
offset := clampInt(r.URL.Query().Get("offset"), 0, 0, 1<<30)
sess := h.db.Cols("id", "username", "avatar_url", "created_at")
if q != "" {
sess = sess.Where("username ILIKE ?", "%"+q+"%")
} else {
sess = sess.Where("1 = 1")
}
sess = sess.Asc("username")
var users []models.User
if err := sess.Limit(limit, offset).Find(&users); err != nil {
jsonError(w, "could not search users", http.StatusInternalServerError)
return
}
results := make([]exploreUser, 0, len(users))
for _, u := range users {
results = append(results, exploreUser{
ID: u.ID,
Username: u.Username,
AvatarURL: u.AvatarURL,
CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z"),
})
}
jsonOK(w, results)
}
func clampInt(s string, def, min, max int) int {
v, err := strconv.Atoi(s)
if err != nil {
return def
}
if v < min {
return min
}
if v > max {
return max
}
return v
}
+4
View File
@@ -50,6 +50,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
webhookH := handlers.NewWebhookHandler(engine)
prSettingsH := handlers.NewPRSettingsHandler(engine)
lfsH := handlers.NewLFSHandler(engine)
exploreH := handlers.NewExploreHandler(engine)
// ── Git smart-HTTP transport ───────────────────────────────────────────────
// These routes MUST be registered before the SPA catch-all and outside CSRF.
@@ -75,6 +76,9 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
r.Route("/api/v1", func(r chi.Router) {
// ── Public ────────────────────────────────────────────────────────────
r.Get("/explore/repos", exploreH.Repos)
r.Get("/explore/users", exploreH.Users)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))