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
+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"}`))