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,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)
|
||||
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"}`))
|
||||
|
||||
Reference in New Issue
Block a user