diff --git a/frontend/src/api/queries/explore.ts b/frontend/src/api/queries/explore.ts new file mode 100644 index 0000000..37e812b --- /dev/null +++ b/frontend/src/api/queries/explore.ts @@ -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 + +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 + +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(`/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(`/api/v1/explore/users?${params}`, exploreUsersSchema), + placeholderData: prev => prev, + }) +} diff --git a/frontend/src/pages/ExplorePage.tsx b/frontend/src/pages/ExplorePage.tsx index 2a5643b..9b8e81b 100644 --- a/frontend/src/pages/ExplorePage.tsx +++ b/frontend/src/pages/ExplorePage.tsx @@ -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 | 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 ( -
-

Explore

-
- +
+
+

Explore

+

Discover public repositories and users.

+
+ + {/* Search bar */} +
+ -
-

Explore public repositories

-

- Federated discovery across ForgeBucket instances — coming soon. -

+ 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 && ( + + )} +
+ + {/* Tabs + sort */} +
+
+
+ {(['repos', 'users'] as const).map(t => ( + + ))} +
+ {tab === 'repos' && ( +
+ Sort: + +
+ )}
+ + {/* Results */} + {tab === 'repos' + ? + : + }
) } + +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 + + if (repos.length === 0 && !isFetching) { + return ( +
+ + + +

{q ? `No repositories match "${q}"` : 'No public repositories yet'}

+

Try a different search term.

+
+ ) + } + + return ( + <> +
    + {repos.map(repo => )} +
+ 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 ( +
  • +
    + {repo.name.slice(0, 1).toUpperCase()} +
    +
    +
    + + {repo.ownerUsername}/{repo.name} + + {repo.isPrivate && ( + Private + )} +
    + {repo.description && ( +

    {repo.description}

    + )} +
    + + + + + + {repo.defaultBranch} + + Updated {timeAgo(repo.updatedAt)} +
    +
    +
  • + ) +} + +function UserResults({ q, offset, setOffset }: { q: string; offset: number; setOffset: (n: number) => void }) { + const { data: users = [], isLoading, isFetching } = useExploreUsers(q, offset) + + if (isLoading) return + + if (users.length === 0 && !isFetching) { + return ( +
    + + + +

    {q ? `No users match "${q}"` : 'No users found'}

    +
    + ) + } + + return ( + <> +
      + {users.map(user => )} +
    + setOffset(Math.max(0, offset - PAGE_SIZE))} onNext={() => setOffset(offset + PAGE_SIZE)} /> + + ) +} + +function UserCard({ user }: { user: ExploreUser }) { + const color = hashColor(user.username) + return ( +
  • + {user.avatarUrl ? ( + {user.username} + ) : ( +
    + {user.username.slice(0, 1).toUpperCase()} +
    + )} +
    + + {user.username} + +

    Joined {timeAgo(user.createdAt)}

    +
    +
  • + ) +} + +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 ( +
    + + Page {page} + +
    + ) +} + +function RepoSkeleton() { + return ( +
      + {Array.from({ length: 5 }).map((_, i) => ( +
    • + +
      + + + +
      +
    • + ))} +
    + ) +} + +function UserSkeleton() { + return ( +
      + {Array.from({ length: 6 }).map((_, i) => ( +
    • + +
      + + +
      +
    • + ))} +
    + ) +} diff --git a/internal/api/handlers/explore.go b/internal/api/handlers/explore.go new file mode 100644 index 0000000..753b713 --- /dev/null +++ b/internal/api/handlers/explore.go @@ -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 +} diff --git a/internal/api/router.go b/internal/api/router.go index ce5301f..4d1fcc3 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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"}`))