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() {
|
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 (
|
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">
|
||||||
<h1 className="text-xl font-semibold text-[var(--c-text)]">Explore</h1>
|
<div>
|
||||||
<div className="flex flex-col items-center justify-center py-16 border border-dashed border-[var(--c-border)] rounded text-center gap-3">
|
<h1 className="text-xl font-semibold text-[var(--c-text)]">Explore</h1>
|
||||||
<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" />
|
<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>
|
</svg>
|
||||||
<div>
|
<input
|
||||||
<p className="text-sm font-medium text-[var(--c-text)]">Explore public repositories</p>
|
value={inputValue}
|
||||||
<p className="text-xs text-[var(--c-muted)] mt-1">
|
onChange={e => setInputValue(e.target.value)}
|
||||||
Federated discovery across ForgeBucket instances — coming soon.
|
placeholder={tab === 'repos' ? 'Search repositories…' : 'Search users…'}
|
||||||
</p>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{tab === 'repos'
|
||||||
|
? <RepoResults q={q} sort={sort} offset={offset} setOffset={setOffset} />
|
||||||
|
: <UserResults q={q} offset={offset} setOffset={setOffset} />
|
||||||
|
}
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
|
|||||||
webhookH := handlers.NewWebhookHandler(engine)
|
webhookH := handlers.NewWebhookHandler(engine)
|
||||||
prSettingsH := handlers.NewPRSettingsHandler(engine)
|
prSettingsH := handlers.NewPRSettingsHandler(engine)
|
||||||
lfsH := handlers.NewLFSHandler(engine)
|
lfsH := handlers.NewLFSHandler(engine)
|
||||||
|
exploreH := handlers.NewExploreHandler(engine)
|
||||||
|
|
||||||
// ── Git smart-HTTP transport ───────────────────────────────────────────────
|
// ── Git smart-HTTP transport ───────────────────────────────────────────────
|
||||||
// These routes MUST be registered before the SPA catch-all and outside CSRF.
|
// 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) {
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
|
|
||||||
// ── Public ────────────────────────────────────────────────────────────
|
// ── Public ────────────────────────────────────────────────────────────
|
||||||
|
r.Get("/explore/repos", exploreH.Repos)
|
||||||
|
r.Get("/explore/users", exploreH.Users)
|
||||||
|
|
||||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Write([]byte(`{"status":"ok"}`))
|
w.Write([]byte(`{"status":"ok"}`))
|
||||||
|
|||||||
Reference in New Issue
Block a user