From 1885488d05d08c054862cf0b5516d16dd077d7a1 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 1 Oct 2026 12:46:42 +0200 Subject: [PATCH 1/4] =?UTF-8?q?Serveur=20Go=20:=20recherche=20GitHub=20par?= =?UTF-8?q?=20crit=C3=A8res?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/search traduit les filtres en requête GitHub (langage, étoiles, activité, création, licence, topic, forks, archivés, good first issues) - cache mémoire des recherches identiques (CACHE_TTL) et suivi du quota - /api/status, /healthz, authentification basique optionnelle - Dockerfile et docker-compose.yml Co-Authored-By: Claude Opus 5.5 (1M context) --- .env.example | 7 ++ .gitignore | 3 + Dockerfile | 19 +++++ api.go | 89 +++++++++++++++++++++ docker-compose.yml | 12 +++ go.mod | 3 + main.go | 95 ++++++++++++++++++++++ query.go | 192 +++++++++++++++++++++++++++++++++++++++++++++ query_test.go | 52 ++++++++++++ 9 files changed, 472 insertions(+) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 api.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 main.go create mode 100644 query.go create mode 100644 query_test.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cb8012a --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Copy to .env and adjust. +HTTP_PORT=8080 +# GitHub token (fine-grained, no permission needed for public repositories). +GITHUB_TOKEN= +CACHE_TTL=5m +AUTH_USER= +AUTH_PASS= diff --git a/.gitignore b/.gitignore index 5b90e79..c23f2ff 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ go.work.sum # env file .env +# Binary +/searchgit + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3dee1b6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Image versions are pinned: update them on purpose. + +# ---- Build ---- +FROM golang:1.27.1-alpine3.24 AS build +WORKDIR /src +COPY go.mod ./ +COPY *.go ./ +COPY web ./web +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/searchgit . + +# ---- Final image ---- +FROM alpine:3.24.2 +RUN apk add --no-cache ca-certificates \ + && adduser -D -H -u 10001 searchgit +COPY --from=build /out/searchgit /usr/local/bin/searchgit +USER searchgit +EXPOSE 8080/tcp +HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1 +ENTRYPOINT ["/usr/local/bin/searchgit"] diff --git a/api.go b/api.go new file mode 100644 index 0000000..44e0035 --- /dev/null +++ b/api.go @@ -0,0 +1,89 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" +) + +type API struct { + gh *GitHub +} + +func (a *API) Routes(mux *http.ServeMux) { + mux.HandleFunc("GET /healthz", a.health) + mux.HandleFunc("GET /api/search", a.search) + mux.HandleFunc("GET /api/status", a.status) +} + +// codedError carries a stable code that the UI translates. +type codedError struct { + code string + msg string + detail string +} + +func (e *codedError) Error() string { + if e.detail != "" { + return e.msg + ": " + e.detail + } + return e.msg +} + +// GET /api/search?q=&language=&stars=&maxstars=&minforks=&topic=&license= +// &pushed=&created=&in=&archived=1&forks=1&goodfirst=1&sort=&order=&page=&per_page= +func (a *API) search(w http.ResponseWriter, r *http.Request) { + c, err := ParseCriteria(r.URL.Query()) + if err != nil { + writeErr(w, http.StatusBadRequest, err) + return + } + res, err := a.gh.Search(r.Context(), c) + if err != nil { + status := http.StatusBadGateway + var ce *codedError + if errors.As(err, &ce) { + switch ce.code { + case "rate_limited": + status = http.StatusTooManyRequests + case "bad_query": + status = http.StatusBadRequest + } + } + writeErr(w, status, err) + return + } + writeJSON(w, http.StatusOK, res) +} + +// GET /api/status: token configured and last known search quota (footer). +func (a *API) status(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "token": a.gh.HasToken(), + "rate": a.gh.Rate(), + }) +} + +func (a *API) health(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// writeErr returns {"error": "...", "code": "...", "detail": "..."}; the UI +// translates known codes and falls back to the English message. +func writeErr(w http.ResponseWriter, status int, err error) { + body := map[string]string{"error": err.Error()} + var ce *codedError + if errors.As(err, &ce) { + body["code"] = ce.code + if ce.detail != "" { + body["detail"] = ce.detail + } + } + writeJSON(w, status, body) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..73191fc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + searchgit: + build: . + container_name: searchgit + restart: unless-stopped + ports: + - "${HTTP_PORT:-8080}:8080" + environment: + GITHUB_TOKEN: ${GITHUB_TOKEN:-} # optional: 30 searches/min instead of 10 + CACHE_TTL: ${CACHE_TTL:-5m} # identical searches are served from memory + AUTH_USER: ${AUTH_USER:-} # leave empty to disable authentication + AUTH_PASS: ${AUTH_PASS:-} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8c4224d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module searchgit + +go 1.23 diff --git a/main.go b/main.go new file mode 100644 index 0000000..f7d41c7 --- /dev/null +++ b/main.go @@ -0,0 +1,95 @@ +// Searchgit: search GitHub repositories with criteria, from a web interface. +package main + +import ( + "context" + "crypto/subtle" + "embed" + "errors" + "io/fs" + "log" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +//go:embed web +var webFS embed.FS + +func getenv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func getenvDuration(key string, def time.Duration) time.Duration { + if d, err := time.ParseDuration(os.Getenv(key)); err == nil && d >= 0 { + return d + } + return def +} + +func main() { + httpAddr := getenv("HTTP_ADDR", ":8080") + token := os.Getenv("GITHUB_TOKEN") + gh := NewGitHub(getenv("GITHUB_API", "https://api.github.com"), token, getenvDuration("CACHE_TTL", 5*time.Minute)) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + static, err := fs.Sub(webFS, "web") + if err != nil { + log.Fatal(err) + } + mux := http.NewServeMux() + (&API{gh: gh}).Routes(mux) + mux.Handle("GET /", http.FileServer(http.FS(static))) + + srv := &http.Server{ + Addr: httpAddr, + Handler: basicAuth(os.Getenv("AUTH_USER"), os.Getenv("AUTH_PASS"), mux), + ReadHeaderTimeout: 10 * time.Second, + BaseContext: func(net.Listener) context.Context { return ctx }, + } + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + if token == "" { + log.Printf("GITHUB_TOKEN not set: GitHub allows only 10 searches per minute") + } + log.Printf("web UI on %s", httpAddr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("http: %v", err) + } + log.Println("shutdown complete") +} + +// basicAuth protects the UI when AUTH_USER is set (except /healthz). +func basicAuth(user, pass string, next http.Handler) http.Handler { + if user == "" { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + next.ServeHTTP(w, r) + return + } + u, p, ok := r.BasicAuth() + if !ok || + subtle.ConstantTimeCompare([]byte(u), []byte(user)) != 1 || + subtle.ConstantTimeCompare([]byte(p), []byte(pass)) != 1 { + w.Header().Set("WWW-Authenticate", `Basic realm="searchgit"`) + http.Error(w, "authentication required", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/query.go b/query.go new file mode 100644 index 0000000..0ebc431 --- /dev/null +++ b/query.go @@ -0,0 +1,192 @@ +package main + +import ( + "fmt" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +// Criteria are the search filters chosen in the UI. They are turned into a +// GitHub search query (https://docs.github.com/search-github/searching-on-github/searching-for-repositories). +type Criteria struct { + Text string // free text, may already contain GitHub qualifiers (topic:cli user:foo…) + Language string + MinStars int + MaxStars int + MinForks int + Topic string + License string // SPDX key in lower case: mit, apache-2.0… + PushedIn time.Duration // last push less than this ago (0 = any) + CreatedIn time.Duration // created less than this ago (0 = any) + In string // where the text is searched: "", "name", "description", "readme" + Archived bool // include archived repositories + Forks bool // include forks + GoodFirst bool // has issues labeled "good first issue" + Sort string // "", "stars", "forks", "updated", "help-wanted-issues" + Order string // "desc" (default) or "asc" + Page int + PerPage int +} + +var ( + sorts = map[string]bool{"": true, "stars": true, "forks": true, "updated": true, "help-wanted-issues": true} + ins = map[string]bool{"": true, "name": true, "description": true, "readme": true} + safeValue = regexp.MustCompile(`^[\w.+#-]+$`) // topic, license + safeLang = regexp.MustCompile(`^[\w.+# -]+$`) // "Jupyter Notebook", "C#", "C++" + durations = map[string]time.Duration{ + "1w": 7 * 24 * time.Hour, "1m": 30 * 24 * time.Hour, "3m": 91 * 24 * time.Hour, + "6m": 182 * 24 * time.Hour, "1y": 365 * 24 * time.Hour, "2y": 730 * 24 * time.Hour, + "5y": 1826 * 24 * time.Hour, + } +) + +// ParseCriteria reads the criteria from the /api/search query string. +func ParseCriteria(v url.Values) (Criteria, error) { + c := Criteria{ + Text: strings.TrimSpace(v.Get("q")), + Language: strings.TrimSpace(v.Get("language")), + Topic: strings.ToLower(strings.TrimSpace(v.Get("topic"))), + License: strings.ToLower(strings.TrimSpace(v.Get("license"))), + In: v.Get("in"), + Archived: v.Get("archived") == "1", + Forks: v.Get("forks") == "1", + GoodFirst: v.Get("goodfirst") == "1", + Sort: v.Get("sort"), + Order: v.Get("order"), + Page: 1, + PerPage: 30, + } + var err error + if c.MinStars, err = intParam(v, "stars"); err != nil { + return c, err + } + if c.MaxStars, err = intParam(v, "maxstars"); err != nil { + return c, err + } + if c.MinForks, err = intParam(v, "minforks"); err != nil { + return c, err + } + if p, err := intParam(v, "page"); err != nil { + return c, err + } else if p > 0 { + c.Page = p + } + if n, err := intParam(v, "per_page"); err != nil { + return c, err + } else if n > 0 { + c.PerPage = min(n, 100) + } + if c.PushedIn, err = durationParam(v, "pushed"); err != nil { + return c, err + } + if c.CreatedIn, err = durationParam(v, "created"); err != nil { + return c, err + } + if c.Language != "" && !safeLang.MatchString(c.Language) { + return c, &codedError{code: "bad_param", msg: "invalid language", detail: "language"} + } + for name, val := range map[string]string{"topic": c.Topic, "license": c.License} { + if val != "" && !safeValue.MatchString(val) { + return c, &codedError{code: "bad_param", msg: "invalid " + name, detail: name} + } + } + if !sorts[c.Sort] { + return c, &codedError{code: "bad_param", msg: "invalid sort", detail: "sort"} + } + if !ins[c.In] { + return c, &codedError{code: "bad_param", msg: "invalid in", detail: "in"} + } + if c.Order != "asc" { + c.Order = "desc" + } + // GitHub never returns more than the first 1000 results. + if c.Page*c.PerPage > 1000 { + return c, &codedError{code: "too_far", msg: "GitHub only returns the first 1000 results"} + } + return c, nil +} + +func intParam(v url.Values, key string) (int, error) { + s := strings.TrimSpace(v.Get(key)) + if s == "" { + return 0, nil + } + n, err := strconv.Atoi(s) + if err != nil || n < 0 { + return 0, &codedError{code: "bad_param", msg: "invalid " + key, detail: key} + } + return n, nil +} + +func durationParam(v url.Values, key string) (time.Duration, error) { + s := v.Get(key) + if s == "" { + return 0, nil + } + d, ok := durations[s] + if !ok { + return 0, &codedError{code: "bad_param", msg: "invalid " + key, detail: key} + } + return d, nil +} + +// Query builds the GitHub "q" parameter. now is passed in for tests. +func (c Criteria) Query(now time.Time) string { + var parts []string + if c.Text != "" { + parts = append(parts, c.Text) + } + if c.In != "" && c.Text != "" { + parts = append(parts, "in:"+c.In) + } + if c.Language != "" { + parts = append(parts, "language:"+quoteIfNeeded(c.Language)) + } + if c.Topic != "" { + parts = append(parts, "topic:"+c.Topic) + } + if c.License != "" { + parts = append(parts, "license:"+c.License) + } + switch { + case c.MinStars > 0 && c.MaxStars > 0: + parts = append(parts, fmt.Sprintf("stars:%d..%d", c.MinStars, c.MaxStars)) + case c.MinStars > 0: + parts = append(parts, fmt.Sprintf("stars:>=%d", c.MinStars)) + case c.MaxStars > 0: + parts = append(parts, fmt.Sprintf("stars:<=%d", c.MaxStars)) + } + if c.MinForks > 0 { + parts = append(parts, fmt.Sprintf("forks:>=%d", c.MinForks)) + } + if c.PushedIn > 0 { + parts = append(parts, "pushed:>="+now.Add(-c.PushedIn).UTC().Format("2006-01-02")) + } + if c.CreatedIn > 0 { + parts = append(parts, "created:>="+now.Add(-c.CreatedIn).UTC().Format("2006-01-02")) + } + if !c.Archived { + parts = append(parts, "archived:false") + } + if c.Forks { + parts = append(parts, "fork:true") + } + if c.GoodFirst { + parts = append(parts, "good-first-issues:>0") + } + // GitHub refuses an empty query: search everything with at least one star. + if c.Text == "" && c.Language == "" && c.Topic == "" && c.License == "" && c.MinStars == 0 && c.MaxStars == 0 { + parts = append(parts, "stars:>=1") + } + return strings.Join(parts, " ") +} + +func quoteIfNeeded(s string) string { + if strings.ContainsAny(s, " ") { + return `"` + s + `"` + } + return s +} diff --git a/query_test.go b/query_test.go new file mode 100644 index 0000000..b4a83cc --- /dev/null +++ b/query_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "net/url" + "testing" + "time" +) + +func TestQuery(t *testing.T) { + now := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + tests := []struct { + params string + want string + }{ + {"", "archived:false stars:>=1"}, + {"q=log+viewer&in=name", "log viewer in:name archived:false"}, + {"language=Go&stars=100", "language:Go stars:>=100 archived:false"}, + {"stars=10&maxstars=500&minforks=5", "stars:10..500 forks:>=5 archived:false"}, + {"topic=CLI&license=MIT&archived=1&forks=1", "topic:cli license:mit fork:true"}, + {"q=syslog&pushed=1y&created=1m&goodfirst=1", "syslog pushed:>=2025-10-01 created:>=2026-09-01 archived:false good-first-issues:>0"}, + } + for _, tt := range tests { + v, _ := url.ParseQuery(tt.params) + c, err := ParseCriteria(v) + if err != nil { + t.Fatalf("%q: %v", tt.params, err) + } + if got := c.Query(now); got != tt.want { + t.Errorf("%q:\n got %q\n want %q", tt.params, got, tt.want) + } + } +} + +func TestParseCriteriaErrors(t *testing.T) { + for _, p := range []string{ + "stars=-1", "stars=abc", "pushed=3d", "sort=name", "in=code", + "language=go%20lang%3Arm", "topic=a:b", "page=40&per_page=30", + } { + v, _ := url.ParseQuery(p) + if _, err := ParseCriteria(v); err == nil { + t.Errorf("%q: expected an error", p) + } + } +} + +func TestPerPageCap(t *testing.T) { + v, _ := url.ParseQuery("per_page=500") + c, err := ParseCriteria(v) + if err != nil || c.PerPage != 100 { + t.Fatalf("per_page=500: got %d, %v", c.PerPage, err) + } +} -- 2.54.0 From 9432a34e30e2097cdd3b2fb1aa6f1b80b3f2e983 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 1 Oct 2026 12:47:35 +0200 Subject: [PATCH 2/4] Client GitHub avec cache, quota et tests ; README Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 71 +++++++++++++- github.go | 249 +++++++++++++++++++++++++++++++++++++++++++++++++ github_test.go | 98 +++++++++++++++++++ 3 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 github.go create mode 100644 github_test.go diff --git a/README.md b/README.md index d67e1af..7197839 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,70 @@ -# searchgit +# Searchgit -Outil Go de recherche de dépôts GitHub selon des critères, avec interface web responsive (light/dark) inspirée de LogStream. \ No newline at end of file +Search GitHub repositories with criteria and browse the results in a clean web interface +(light/dark theme, responsive, English/French UI). The interface reuses the look of +[Logstream](https://gitea.vlab.bzh/cedric/logstream). + +``` +browser ──/api/search──▶ searchgit (Go) ──REST──▶ api.github.com/search/repositories + └── 5 min cache +``` + +## Getting started + +```bash +cp .env.example .env # optional: set GITHUB_TOKEN +docker compose up -d --build +``` + +Then open . + +Without Docker: `go run .` (Go 1.23 or newer), then open . + +## Criteria + +| Filter | GitHub qualifier | +|---|---| +| Search field | free text, plus any GitHub qualifier (`user:`, `org:`, `NOT`, `"exact phrase"`, `size:`…) | +| Everywhere / Name / Description / README | `in:name`, `in:description`, `in:readme` | +| Language | `language:Go` (click a language in the list to filter on it) | +| Stars | `stars:>=100` | +| Activity | `pushed:>=YYYY-MM-DD` (1 month to 2 years) | +| Creation date | `created:>=YYYY-MM-DD` | +| License | `license:mit`… | +| Topic | `topic:cli` (click a topic in the list to filter on it) | +| Good first issues | `good-first-issues:>0` | +| Forks / Archived | forks and archived repositories are hidden unless enabled | +| Sort | best match, stars, forks, recently updated, help wanted issues | + +The filters are kept in the page URL, so a search can be bookmarked or shared. The footer +shows the exact query sent to GitHub (click it to open the same search on github.com). +**CSV** downloads the loaded results (semicolon separated when the UI is in French, for Excel). + +GitHub returns at most the first 1,000 results of a search. + +## Configuration + +| Variable | Default | Role | +|---|---|---| +| `HTTP_ADDR` | `:8080` | listening address | +| `GITHUB_TOKEN` | empty | GitHub token; raises the search quota from 10 to 30 requests per minute | +| `CACHE_TTL` | `5m` | identical searches are served from memory for this long | +| `AUTH_USER` / `AUTH_PASS` | empty | HTTP basic authentication (except `/healthz`) | +| `GITHUB_API` | `https://api.github.com` | API base URL (GitHub Enterprise) | + +## API + +- `GET /api/search?q=&in=&language=&stars=&maxstars=&minforks=&topic=&license=&pushed=&created=&goodfirst=1&forks=1&archived=1&sort=&order=&page=&per_page=` + (`pushed` / `created`: `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `5y`) +- `GET /api/status`: token configured and last known search quota +- `GET /healthz` + +## Development + +```bash +go test ./... +go run . +``` + +The web interface is plain HTML, CSS and JavaScript in `web/`, embedded in the binary +(no build step). diff --git a/github.go b/github.go new file mode 100644 index 0000000..5c8fc78 --- /dev/null +++ b/github.go @@ -0,0 +1,249 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "sync" + "time" +) + +// Repo is what the UI shows for one repository. +type Repo struct { + FullName string `json:"fullName"` + Name string `json:"name"` + Owner string `json:"owner"` + Avatar string `json:"avatar"` + URL string `json:"url"` + Homepage string `json:"homepage,omitempty"` + Description string `json:"description"` + Language string `json:"language"` + Topics []string `json:"topics"` + License string `json:"license,omitempty"` + Stars int `json:"stars"` + Forks int `json:"forks"` + Issues int `json:"issues"` + Archived bool `json:"archived"` + Fork bool `json:"fork"` + CreatedAt time.Time `json:"createdAt"` + PushedAt time.Time `json:"pushedAt"` +} + +// SearchResult is the /api/search response. +type SearchResult struct { + Query string `json:"query"` // GitHub query actually sent + Total int `json:"total"` + Incomplete bool `json:"incomplete"` + Page int `json:"page"` + PerPage int `json:"perPage"` + Items []Repo `json:"items"` + Rate RateLimit `json:"rate"` + Cached bool `json:"cached"` + TookMs int64 `json:"tookMs"` +} + +// RateLimit is the search quota reported by GitHub. +type RateLimit struct { + Limit int `json:"limit"` + Remaining int `json:"remaining"` + Reset time.Time `json:"reset"` +} + +type GitHub struct { + base string + token string + client *http.Client + ttl time.Duration + + mu sync.Mutex + cache map[string]cacheEntry + rate RateLimit +} + +type cacheEntry struct { + res SearchResult + expires time.Time +} + +func NewGitHub(base, token string, ttl time.Duration) *GitHub { + return &GitHub{ + base: base, + token: token, + client: &http.Client{Timeout: 20 * time.Second}, + ttl: ttl, + cache: map[string]cacheEntry{}, + } +} + +func (g *GitHub) HasToken() bool { return g.token != "" } + +// Rate returns the last known search quota. +func (g *GitHub) Rate() RateLimit { + g.mu.Lock() + defer g.mu.Unlock() + return g.rate +} + +// Search runs a repository search. Identical queries are served from the +// cache for ttl, which saves the small search quota (10/min without token, +// 30/min with one). +func (g *GitHub) Search(ctx context.Context, c Criteria) (SearchResult, error) { + q := c.Query(time.Now()) + v := url.Values{} + v.Set("q", q) + if c.Sort != "" { + v.Set("sort", c.Sort) + v.Set("order", c.Order) + } + v.Set("per_page", strconv.Itoa(c.PerPage)) + v.Set("page", strconv.Itoa(c.Page)) + key := v.Encode() + + g.mu.Lock() + if e, ok := g.cache[key]; ok && time.Now().Before(e.expires) { + g.mu.Unlock() + res := e.res + res.Cached = true + res.TookMs = 0 + res.Rate = g.Rate() + return res, nil + } + g.mu.Unlock() + + start := time.Now() + var body struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []struct { + FullName string `json:"full_name"` + Name string `json:"name"` + HTMLURL string `json:"html_url"` + Homepage string `json:"homepage"` + Description string `json:"description"` + Language string `json:"language"` + Topics []string `json:"topics"` + Stars int `json:"stargazers_count"` + Forks int `json:"forks_count"` + Issues int `json:"open_issues_count"` + Archived bool `json:"archived"` + Fork bool `json:"fork"` + CreatedAt time.Time `json:"created_at"` + PushedAt time.Time `json:"pushed_at"` + Owner struct { + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + } `json:"owner"` + License *struct { + SpdxID string `json:"spdx_id"` + } `json:"license"` + } `json:"items"` + } + if err := g.get(ctx, "/search/repositories?"+key, &body); err != nil { + return SearchResult{}, err + } + + res := SearchResult{ + Query: q, + Total: body.TotalCount, + Incomplete: body.IncompleteResults, + Page: c.Page, + PerPage: c.PerPage, + Items: make([]Repo, 0, len(body.Items)), + TookMs: time.Since(start).Milliseconds(), + } + for _, it := range body.Items { + r := Repo{ + FullName: it.FullName, Name: it.Name, Owner: it.Owner.Login, Avatar: it.Owner.AvatarURL, + URL: it.HTMLURL, Homepage: it.Homepage, Description: it.Description, Language: it.Language, + Topics: it.Topics, Stars: it.Stars, Forks: it.Forks, Issues: it.Issues, + Archived: it.Archived, Fork: it.Fork, CreatedAt: it.CreatedAt, PushedAt: it.PushedAt, + } + if r.Topics == nil { + r.Topics = []string{} + } + if it.License != nil && it.License.SpdxID != "NOASSERTION" { + r.License = it.License.SpdxID + } + res.Items = append(res.Items, r) + } + + g.mu.Lock() + now := time.Now() + for k, e := range g.cache { // drop expired entries + if now.After(e.expires) { + delete(g.cache, k) + } + } + g.cache[key] = cacheEntry{res: res, expires: now.Add(g.ttl)} + res.Rate = g.rate + g.mu.Unlock() + return res, nil +} + +// get calls the GitHub API and decodes the JSON answer into out. +func (g *GitHub) get(ctx context.Context, path string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.base+path, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", "searchgit") + if g.token != "" { + req.Header.Set("Authorization", "Bearer "+g.token) + } + resp, err := g.client.Do(req) + if err != nil { + return &codedError{code: "github_unreachable", msg: "cannot reach GitHub", detail: err.Error()} + } + defer resp.Body.Close() + g.updateRate(resp.Header) + + data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + var e struct { + Message string `json:"message"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + _ = json.Unmarshal(data, &e) + detail := e.Message + if len(e.Errors) > 0 && e.Errors[0].Message != "" { + detail = e.Errors[0].Message + } + switch { + case resp.StatusCode == http.StatusUnauthorized: + return &codedError{code: "github_token", msg: "GitHub refused the token (GITHUB_TOKEN)", detail: detail} + case resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests: + return &codedError{code: "rate_limited", msg: "GitHub search quota exhausted, try again in a minute", detail: detail} + case resp.StatusCode == http.StatusUnprocessableEntity: + return &codedError{code: "bad_query", msg: "GitHub rejected the query", detail: detail} + } + return fmt.Errorf("GitHub returned %s: %s", resp.Status, detail) + } + return json.Unmarshal(data, out) +} + +func (g *GitHub) updateRate(h http.Header) { + // Only the search quota matters here (resource "search"). + if res := h.Get("X-RateLimit-Resource"); res != "" && res != "search" { + return + } + limit, err1 := strconv.Atoi(h.Get("X-RateLimit-Limit")) + remaining, err2 := strconv.Atoi(h.Get("X-RateLimit-Remaining")) + reset, err3 := strconv.ParseInt(h.Get("X-RateLimit-Reset"), 10, 64) + if err1 != nil || err2 != nil || err3 != nil { + return + } + g.mu.Lock() + g.rate = RateLimit{Limit: limit, Remaining: remaining, Reset: time.Unix(reset, 0)} + g.mu.Unlock() +} diff --git a/github_test.go b/github_test.go new file mode 100644 index 0000000..37b8a7a --- /dev/null +++ b/github_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + "time" +) + +// fakeGitHub answers every request with a fixed status, headers and body. +type fakeGitHub struct { + status int + header http.Header + body string + calls int + last *http.Request +} + +func (f *fakeGitHub) RoundTrip(r *http.Request) (*http.Response, error) { + f.calls++ + f.last = r + return &http.Response{ + StatusCode: f.status, + Status: http.StatusText(f.status), + Header: f.header, + Body: io.NopCloser(strings.NewReader(f.body)), + Request: r, + }, nil +} + +func newFake(status int, body string) (*GitHub, *fakeGitHub) { + f := &fakeGitHub{status: status, body: body, header: http.Header{ + "X-Ratelimit-Limit": {"10"}, + "X-Ratelimit-Remaining": {"9"}, + "X-Ratelimit-Reset": {"1790851447"}, + "X-Ratelimit-Resource": {"search"}, + }} + gh := NewGitHub("https://api.github.test", "secret", time.Minute) + gh.client = &http.Client{Transport: f} + return gh, f +} + +const searchBody = `{"total_count": 14, "incomplete_results": false, "items": [{ + "full_name": "mcuadros/go-syslog", "name": "go-syslog", "html_url": "https://github.com/mcuadros/go-syslog", + "description": "Syslog server library for go", "language": "Go", "topics": null, + "stargazers_count": 540, "forks_count": 150, "open_issues_count": 20, "archived": false, "fork": false, + "created_at": "2014-09-01T10:00:00Z", "pushed_at": "2024-03-01T10:00:00Z", + "owner": {"login": "mcuadros", "avatar_url": "https://avatars.githubusercontent.com/u/1573114?v=4"}, + "license": {"spdx_id": "MIT"}}]}` + +func TestSearchMapsAndCaches(t *testing.T) { + gh, f := newFake(http.StatusOK, searchBody) + c := Criteria{Text: "syslog", Language: "Go", Sort: "stars", Order: "desc", Page: 1, PerPage: 30} + + res, err := gh.Search(context.Background(), c) + if err != nil { + t.Fatal(err) + } + if res.Total != 14 || len(res.Items) != 1 { + t.Fatalf("got total %d, %d items", res.Total, len(res.Items)) + } + r := res.Items[0] + if r.FullName != "mcuadros/go-syslog" || r.Owner != "mcuadros" || r.License != "MIT" || r.Stars != 540 || r.Topics == nil { + t.Errorf("bad mapping: %+v", r) + } + if res.Rate.Limit != 10 || res.Rate.Remaining != 9 { + t.Errorf("bad rate: %+v", res.Rate) + } + q := f.last.URL.Query() + if q.Get("sort") != "stars" || q.Get("q") != "syslog language:Go archived:false" { + t.Errorf("bad request: %s", f.last.URL) + } + if f.last.Header.Get("Authorization") != "Bearer secret" { + t.Errorf("token not sent") + } + + res, err = gh.Search(context.Background(), c) + if err != nil || !res.Cached || f.calls != 1 { + t.Errorf("second identical search should come from the cache (calls=%d, cached=%v, err=%v)", f.calls, res.Cached, err) + } +} + +func TestSearchErrors(t *testing.T) { + for status, code := range map[int]string{ + http.StatusForbidden: "rate_limited", + http.StatusUnauthorized: "github_token", + http.StatusUnprocessableEntity: "bad_query", + } { + gh, _ := newFake(status, `{"message": "nope", "errors": [{"message": "detail"}]}`) + _, err := gh.Search(context.Background(), Criteria{Page: 1, PerPage: 30}) + ce, ok := err.(*codedError) + if !ok || ce.code != code || ce.detail != "detail" { + t.Errorf("status %d: got %v, want code %s", status, err, code) + } + } +} -- 2.54.0 From 707962d5e43e416f8accd24bb39a125cdb9197f8 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 1 Oct 2026 12:49:25 +0200 Subject: [PATCH 3/4] Interface : structure HTML et styles repris de Logstream Co-Authored-By: Claude Opus 5.5 (1M context) --- web/index.html | 225 ++++++++++++++++++++++++++++ web/style.css | 388 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 613 insertions(+) create mode 100644 web/index.html create mode 100644 web/style.css diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..946dd2a --- /dev/null +++ b/web/index.html @@ -0,0 +1,225 @@ + + + + + + Searchgit + + + + + +
+
+ + Searchgit +
+ + + +
+ + +
+ +
+ +
+ + + + + + +
+ + + +
+ +
+ + + +
+
+ + + +
+ + + +
+ + +
+ + +
+
+

Settings

+ +
+ +
+ + +
+ +
+
+

Language

+
+ + +
+
+
+ + + + + + +
+
+
+
+ + + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..3c02770 --- /dev/null +++ b/web/style.css @@ -0,0 +1,388 @@ +/* Look and feel taken from Logstream: same tokens, top bar, filters, list and settings dialog. */ + +/* ---------- Theme: everything goes through these variables ---------- */ +:root { + --bg: #f6f7f9; + --panel: #ffffff; + --panel-2: #f0f2f5; + --border: #e2e5ea; + --text: #1a1d23; + --muted: #6b7280; + --accent: #6366f1; + --accent-soft: rgba(99, 102, 241, .12); + --row-hover: #f5f6fa; + --shadow: 0 10px 30px rgba(15, 23, 42, .12); + --star: #d97706; + --ok: #0d9488; + --err: #dc2626; + --warn: #d97706; + + --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace; + --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --radius: 10px; + --row-pad: 12px; /* Settings > Interface > Density */ + color-scheme: light; +} + +:root[data-theme="dark"] { + --bg: #0f1115; + --panel: #161a20; + --panel-2: #1d222a; + --border: #2a3039; + --text: #e6e8ec; + --muted: #8b93a1; + --accent: #818cf8; + --accent-soft: rgba(129, 140, 248, .16); + --row-hover: #1a1f26; + --shadow: 0 10px 30px rgba(0, 0, 0, .5); + --star: #fbbf24; + --ok: #2dd4bf; + --err: #f87171; + --warn: #fbbf24; + color-scheme: dark; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #0f1115; + --panel: #161a20; + --panel-2: #1d222a; + --border: #2a3039; + --text: #e6e8ec; + --muted: #8b93a1; + --accent: #818cf8; + --accent-soft: rgba(129, 140, 248, .16); + --row-hover: #1a1f26; + --shadow: 0 10px 30px rgba(0, 0, 0, .5); + --star: #fbbf24; + --ok: #2dd4bf; + --err: #f87171; + --warn: #fbbf24; + color-scheme: dark; + } +} + +:root[data-density="compact"] { --row-pad: 7px; } + +/* ---------- Base ---------- */ +* { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 14px/1.45 var(--sans); + padding-bottom: 44px; +} +button, input, select { font: inherit; color: inherit; } +button { cursor: pointer; } +a { color: inherit; } +svg { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +.muted { color: var(--muted); } +.small { font-size: 12.5px; } +[hidden] { display: none !important; } + +/* ---------- Top bar ---------- */ +.topbar { + position: sticky; top: 0; z-index: 20; + display: flex; align-items: center; gap: 16px; + padding: 10px 20px; + background: color-mix(in srgb, var(--panel) 88%, transparent); + backdrop-filter: saturate(160%) blur(10px); + -webkit-backdrop-filter: saturate(160%) blur(10px); + border-bottom: 1px solid var(--border); +} +.brand { display: flex; align-items: center; gap: 10px; font-weight: 650; font-size: 16px; letter-spacing: -.01em; } +.brand svg { width: 26px; height: 26px; } +.brand svg rect { fill: var(--accent); stroke: none; } +.brand svg circle, .brand svg path { stroke: #fff; stroke-width: 3; } + +.search { + flex: 1; max-width: 720px; margin: 0; + display: flex; align-items: center; gap: 8px; + padding: 0 5px 0 12px; height: 38px; + background: var(--panel-2); + border: 1px solid transparent; + border-radius: var(--radius); + transition: border-color .15s, box-shadow .15s; +} +.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.search .ico { width: 17px; height: 17px; color: var(--muted); flex: none; } +.search input { + flex: 1; min-width: 0; height: 100%; + border: 0; outline: 0; background: transparent; + font-family: var(--mono); font-size: 13px; +} +.search input::-webkit-search-cancel-button { cursor: pointer; } +.chip-select { + flex: none; height: 28px; padding: 0 24px 0 9px; + border: 1px solid var(--border); border-radius: 7px; + background: var(--panel) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b93a1' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 6px center / 12px; + appearance: none; -webkit-appearance: none; + font-size: 12px; font-weight: 600; color: var(--muted); +} +.chip-select.set { color: var(--accent); border-color: var(--accent); background-color: var(--accent-soft); } + +.actions { display: flex; align-items: center; gap: 6px; margin-left: auto; } +.icon-btn { + width: 36px; height: 36px; display: grid; place-items: center; + border: 1px solid transparent; background: transparent; border-radius: 9px; color: var(--muted); +} +.icon-btn:hover { background: var(--panel-2); color: var(--text); } +.icon-btn svg { width: 19px; height: 19px; } + +/* Sun or moon depending on the effective theme */ +#themeBtn .moon { display: none; } +:root[data-theme="dark"] #themeBtn .sun { display: none; } +:root[data-theme="dark"] #themeBtn .moon { display: block; } +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) #themeBtn .sun { display: none; } + :root:not([data-theme="light"]) #themeBtn .moon { display: block; } +} + +.btn { + display: inline-flex; align-items: center; gap: 7px; + height: 34px; padding: 0 12px; + border: 1px solid var(--border); border-radius: 9px; + background: var(--panel); font-weight: 550; font-size: 13px; text-decoration: none; +} +.btn:hover { background: var(--panel-2); } +.btn:disabled { opacity: .5; cursor: not-allowed; } +.btn.tool { height: 32px; padding: 0 10px; } +.btn.tool svg { width: 16px; height: 16px; } +.link-btn { border: 0; background: none; padding: 0; font-size: 12.5px; font-weight: 550; color: var(--accent); white-space: nowrap; } +.link-btn:hover { text-decoration: underline; } + +.progress { position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; overflow: hidden; } +body.busy .progress::after { + content: ""; position: absolute; inset: 0; width: 30%; + background: var(--accent); + animation: slide 1s ease-in-out infinite; +} +@keyframes slide { from { transform: translateX(-100%); } to { transform: translateX(400%); } } + +/* ---------- Filters ---------- */ +.filters { + display: flex; flex-wrap: wrap; align-items: center; gap: 8px; + padding: 12px 20px 10px; +} +.filters select, .filter-input { + height: 32px; border: 1px solid var(--border); border-radius: 8px; + background-color: var(--panel); font-size: 13px; +} +.filters select { + padding: 0 28px 0 10px; max-width: 220px; + background: var(--panel) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b93a1' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 8px center / 14px; + appearance: none; -webkit-appearance: none; +} +.filter-input { width: 9.5rem; padding: 0 10px; font-family: var(--mono); font-size: 12.5px; outline: 0; } +.filter-input:focus, .filters select:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.filters select.set, .filter-input.set { border-color: var(--accent); background-color: var(--accent-soft); } +.toggles { display: inline-flex; flex-wrap: wrap; gap: 6px; } +.toggle { + height: 32px; padding: 0 11px; border: 1px solid var(--border); border-radius: 8px; + background: var(--panel); font-size: 12.5px; font-weight: 600; color: var(--muted); white-space: nowrap; +} +.toggle:hover { color: var(--text); } +.toggle[aria-pressed="true"] { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); } +.filters .end { display: flex; align-items: center; gap: 8px; margin-left: auto; } +#count { font-size: 12.5px; font-variant-numeric: tabular-nums; } + +/* ---------- Result list ---------- */ +.list { margin: 4px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } +.list:empty { display: none; } + +.row { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + grid-template-areas: "av main meta" "av details details"; + gap: 0 14px; align-items: start; + padding: var(--row-pad) 16px var(--row-pad) 14px; + border-bottom: 1px solid var(--border); + cursor: pointer; +} +.row:last-child { border-bottom: 0; } +.row:hover { background: var(--row-hover); } +.row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.row.open { background: var(--row-hover); } +.row .av { grid-area: av; width: 36px; height: 36px; border-radius: 8px; background: var(--panel-2); margin-top: 2px; } +:root[data-density="compact"] .row .av { width: 28px; height: 28px; } +.row .main { grid-area: main; min-width: 0; } +.row .name { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; } +.row .name a { font-weight: 650; text-decoration: none; overflow-wrap: anywhere; } +.row .name a .owner { font-weight: 450; color: var(--muted); } +.row .name a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; } +.row .desc { margin: 2px 0 0; color: var(--text); opacity: .85; overflow-wrap: anywhere; } +:root[data-density="compact"] .row .desc { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.badge { + font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; + padding: 1px 6px; border-radius: 5px; color: var(--muted); border: 1px solid var(--border); +} +.badge.archived { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, var(--border)); } +.topics { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; } +:root[data-density="compact"] .topics { display: none; } +.topic { + height: 22px; padding: 0 8px; border: 0; border-radius: 999px; + background: var(--accent-soft); color: var(--accent); font-size: 11.5px; font-weight: 600; +} +.topic:hover { filter: brightness(1.15); text-decoration: underline; } + +.meta { + grid-area: meta; + display: grid; grid-template-columns: 7.5rem 4.5rem 4rem 6.5rem; gap: 12px; + align-items: center; padding-top: 2px; + font-size: 12.5px; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; +} +.meta > span { display: inline-flex; align-items: center; gap: 5px; overflow: hidden; text-overflow: ellipsis; } +.meta svg { width: 14px; height: 14px; flex: none; } +.meta .stars { color: var(--text); font-weight: 600; } +.meta .stars svg { color: var(--star); fill: var(--star); } +.lang-dot { width: 10px; height: 10px; border-radius: 50%; flex: none; background: var(--c, var(--muted)); } +.meta .lang[data-act]:hover { color: var(--accent); } + +/* Repository details */ +.details { + grid-area: details; + margin: 10px 0 2px; padding: 10px 12px; + background: var(--panel-2); border-radius: 8px; cursor: auto; font-size: 12.5px; +} +.details dl { display: grid; grid-template-columns: max-content 1fr; gap: 3px 16px; margin: 0; } +.details dt { color: var(--muted); } +.details dd { margin: 0; word-break: break-all; font-family: var(--mono); } +.details .acts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } +.details .acts .btn { height: 28px; font-size: 12px; } + +.more { display: flex; justify-content: center; padding: 14px 20px 4px; } + +/* ---------- States ---------- */ +.empty { text-align: center; padding: 60px 20px; } +.empty svg { width: 40px; height: 40px; color: var(--muted); opacity: .6; } +.empty p { margin: 6px 0; } + +.error-banner { + margin: 4px 20px 8px; padding: 10px 14px; + border: 1px solid color-mix(in srgb, var(--err) 40%, transparent); + background: color-mix(in srgb, var(--err) 10%, transparent); + color: var(--err); border-radius: var(--radius); + font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; +} + +.status { + position: fixed; left: 0; right: 0; bottom: 0; z-index: 10; + display: flex; gap: 16px; align-items: center; + padding: 6px 20px; font-size: 12px; + background: var(--panel); border-top: 1px solid var(--border); + font-variant-numeric: tabular-nums; +} +.conn { white-space: nowrap; } +.conn::before { content: "●"; margin-right: 6px; color: var(--muted); } +.conn.ok::before { color: var(--ok); } +.conn.warn::before { color: var(--warn); } +.conn.ko::before { color: var(--err); } +.gh-query { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-family: var(--mono); font-size: 11.5px; color: var(--muted); text-decoration: none; +} +.gh-query:hover { color: var(--accent); text-decoration: underline; } + +.toast { + position: fixed; bottom: 48px; left: 50%; transform: translateX(-50%); z-index: 50; + background: var(--text); color: var(--bg); + padding: 8px 14px; border-radius: 8px; font-size: 13px; box-shadow: var(--shadow); +} + +/* ---------- Settings dialog ---------- */ +dialog { + width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 64px); + padding: 0; border: 1px solid var(--border); border-radius: 14px; + background: var(--panel); color: var(--text); box-shadow: var(--shadow); +} +dialog::backdrop { background: rgba(10, 12, 16, .45); backdrop-filter: blur(2px); } +dialog.settings { + width: min(820px, calc(100vw - 32px)); + height: min(560px, calc(100vh - 64px)); max-height: none; +} +.dlg { display: flex; flex-direction: column; height: 100%; } +.dlg > header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 14px 22px; border-bottom: 1px solid var(--border); } +.dlg h2 { margin: 0; font-size: 17px; } +.set-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 200px minmax(0, 1fr); } +.set-nav { + display: flex; flex-direction: column; gap: 2px; + padding: 12px 10px; border-right: 1px solid var(--border); + background: color-mix(in srgb, var(--panel-2) 50%, transparent); +} +.set-nav button { + display: flex; align-items: center; gap: 10px; + height: 38px; padding: 0 12px; border: 0; border-radius: 9px; + background: transparent; color: var(--muted); font-weight: 560; font-size: 13.5px; text-align: left; + white-space: nowrap; +} +.set-nav button:hover { background: var(--panel-2); color: var(--text); } +.set-nav button[aria-selected="true"] { background: var(--accent-soft); color: var(--accent); } +.set-nav svg { width: 18px; height: 18px; flex: none; } +.set-panels { overflow-y: auto; padding: 2px 24px 22px; } +.set-panel > .set-section:first-child { margin-top: 16px; } +.set-section { margin-top: 18px; } +.set-section + .set-section { padding-top: 16px; border-top: 1px solid var(--border); } +.set-section h3 { margin: 0 0 8px; font-size: 13px; font-weight: 650; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); } +.set-section p { margin: 0; } +.hint { margin-top: 8px !important; } +.lbl { font-size: 13px; font-weight: 550; } +.field-grid { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 10px 14px; align-items: center; justify-items: start; } +.state-line { font-size: 13px; margin: 0 0 8px !important; } +.state-line.good::before, .state-line.bad::before, .state-line.warn::before { content: "●"; margin-right: 6px; } +.state-line.good::before { color: var(--ok); } +.state-line.warn::before { color: var(--warn); } +.state-line.bad { color: var(--err); } +.syntax { margin: 10px 0 0; padding: 0; list-style: none; display: grid; gap: 6px; font-size: 12.5px; } +.syntax code { + font: 12px var(--mono); padding: 2px 7px; border-radius: 5px; + background: var(--panel-2); border: 1px solid var(--border); margin-right: 6px; +} + +.seg { display: inline-flex; padding: 3px; gap: 3px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; } +.seg button { + border: 0; background: transparent; border-radius: 7px; + height: 30px; padding: 0 14px; font-size: 13px; font-weight: 600; color: var(--muted); +} +.seg button:hover { color: var(--text); } +.seg button[aria-checked="true"] { background: var(--panel); color: var(--accent); box-shadow: 0 1px 3px rgba(0, 0, 0, .12); } + +/* ---------- Medium screens: the meta columns go under the description ---------- */ +@media (max-width: 1100px) { + .row { grid-template-columns: 36px minmax(0, 1fr); grid-template-areas: "av main" "av meta" "av details"; } + .meta { display: flex; flex-wrap: wrap; gap: 4px 16px; padding-top: 6px; } +} + +/* ---------- Mobile ---------- */ +@media (max-width: 760px) { + body { font-size: 13.5px; } + .topbar { flex-wrap: wrap; padding: 8px 16px; gap: 8px 10px; } + .search { order: 3; flex-basis: 100%; max-width: none; } + .filters { padding: 10px 16px 8px; } + .filters select, .filter-input { flex: 1 1 calc(50% - 8px); max-width: none; min-width: 0; width: auto; } + .toggles { flex: 1 1 100%; } + .toggle { flex: 1; padding: 0 6px; } + .filters .end { flex: 1 1 100%; margin-left: 0; } + #count { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .filters .end select { flex: 0 1 auto; } + .list, .error-banner { margin-left: 16px; margin-right: 16px; } + .row { grid-template-columns: 28px minmax(0, 1fr); gap: 0 10px; padding-left: 12px; padding-right: 12px; } + .row .av { width: 28px; height: 28px; } + .details dl { grid-template-columns: 1fr; } + .details dt { margin-top: 4px; } + .status { padding: 6px 16px; } + .gh-query { display: none; } + dialog.settings { height: calc(100vh - 24px); width: calc(100vw - 16px); } + .set-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); } + .set-nav { + display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 6px; + border-right: 0; border-bottom: 1px solid var(--border); + } + .set-nav button { + flex-direction: column; justify-content: center; height: auto; padding: 6px 2px; gap: 3px; + font-size: 11.5px; text-align: center; + } + .set-panels { padding: 0 16px 18px; } + .field-grid { grid-template-columns: 1fr; gap: 6px; } +} -- 2.54.0 From de88cd7b062b2fc0735f10f8fa1af0247b00d377 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 1 Oct 2026 12:50:59 +0200 Subject: [PATCH 4/4] =?UTF-8?q?Interface=20:=20recherche,=20filtres,=20lis?= =?UTF-8?q?te,=20r=C3=A9glages,=20th=C3=A8me=20et=20export=20CSV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- web/app.js | 674 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 web/app.js diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..7f39034 --- /dev/null +++ b/web/app.js @@ -0,0 +1,674 @@ +'use strict'; + +/* ================= Translations ================= */ + +const I18N = { + en: { + locale: 'en-US', + searchAria: 'Search repositories', + searchPh: 'Search… log viewer user:grafana NOT electron (press /)', + inAria: 'Search in', inAll: 'Everywhere', inName: 'Name', inDesc: 'Description', inReadme: 'README', + settings: 'Settings', theme: 'Light / dark theme', close: 'Close', + languageAria: 'Language', langAll: 'All languages', + starsAria: 'Stars', starsAll: 'Any stars', + pushedAria: 'Last push', pushedAll: 'Any activity', + pushed1m: 'Pushed < 1 month', pushed6m: 'Pushed < 6 months', pushed1y: 'Pushed < 1 year', pushed2y: 'Pushed < 2 years', + createdAria: 'Creation date', createdAll: 'Any creation date', + created1m: 'Created < 1 month', created1y: 'Created < 1 year', created5y: 'Created < 5 years', + licenseAria: 'License', licenseAll: 'Any license', + topicPh: 'topic', topicAria: 'Topic', + goodFirst: 'Good first issues', goodFirstTitle: 'Only repositories with issues labeled "good first issue"', + withForks: 'Forks', withForksTitle: 'Include forks in the results', + withArchived: 'Archived', withArchivedTitle: 'Include archived (read-only) repositories', + reset: 'Reset filters', + sortAria: 'Sort', sortBest: 'Best match', sortStars: 'Most stars', sortForks: 'Most forks', + sortUpdated: 'Recently updated', sortHelp: 'Help wanted issues', + export: 'CSV', exportTitle: 'Download the loaded results as CSV', + exportNone: 'Nothing to export', exportDone: (f) => `Downloaded: ${f}`, + more: 'Load more', moreN: (n) => `Load ${n} more`, + emptyTitle: 'No matching repositories.', + emptyHint: 'Loosen a filter or change the search words.', + count: ({ shown, total }) => `${shown} of ${total} ${total === 1 ? 'repository' : 'repositories'}`, + countCapped: 'GitHub only returns the first 1,000 results: refine the search to see the others.', + fork: 'fork', archived: 'archived', + updated: (s) => `updated ${s}`, + openGh: 'Open on GitHub', homepage: 'Website', copyClone: 'Copy clone URL', copyName: 'Copy name', copied: 'Copied', + filterLang: 'Click to filter on this language', filterTopic: 'Click to filter on this topic', + fFull: 'repository', fCreated: 'created', fPushed: 'last push', fLicense: 'license', fIssues: 'open issues', + fForks: 'forks', fStars: 'stars', fClone: 'clone', + noLicense: 'none', + searchError: 'Search error: ', + unreachable: 'server unreachable', + connToken: 'GitHub · token', connNoToken: 'GitHub · no token', + quota: ({ r, l }) => `${r}/${l} searches left`, + quotaReset: (s) => `reset in ${s}s`, + ghQueryTitle: 'Query sent to GitHub: click to open it on github.com', + err_bad_param: 'Invalid filter value', + err_too_far: 'GitHub only returns the first 1,000 results', + err_rate_limited: 'GitHub search quota exhausted: wait a minute (or set GITHUB_TOKEN on the server)', + err_bad_query: 'GitHub rejected the query', + err_github_token: 'GitHub refused the token (GITHUB_TOKEN)', + err_github_unreachable: 'Cannot reach GitHub', + language: 'Language', + tabLocale: 'Localization', tabInterface: 'Interface', + themeTitle: 'Theme', themeSystem: 'System', themeLight: 'Light', themeDark: 'Dark', + themeHelp: 'System follows the light/dark preference of your computer or phone.', + listTitle: 'Result list', density: 'Density', compact: 'Compact', comfortable: 'Comfortable', + perPage: 'Results per page', + tokenTitle: 'Access token', + tokenOn: 'A GitHub token is configured on the server.', + tokenOff: 'No GitHub token: searches are anonymous and limited to 10 per minute.', + tokenHelp: 'Set GITHUB_TOKEN on the server (a fine-grained token with no permission is enough for public repositories). It raises the quota to 30 searches per minute.', + quotaTitle: 'Search quota', + quotaUnknown: 'Unknown until the first search.', + quotaLine: ({ r, l, s }) => `${r} of ${l} searches left, reset in ${s} s.`, + quotaFull: ({ l }) => `${l} searches available.`, + quotaHelp: 'Identical searches are served from a 5 minute cache on the server and do not use the quota.', + syntaxTitle: 'Search syntax', + syntaxHelp: 'The search field accepts GitHub qualifiers, combined with the filters above the list:', + synUser: 'repositories of a user', synOrg: 'repositories of an organization', synTopics: 'several topics at once', + synPhrase: 'exact phrase', synNot: 'exclude a word', synSize: 'size in KB', + }, + fr: { + locale: 'fr-FR', + searchAria: 'Rechercher des dépôts', + searchPh: 'Rechercher… log viewer user:grafana NOT electron (touche /)', + inAria: 'Chercher dans', inAll: 'Partout', inName: 'Nom', inDesc: 'Description', inReadme: 'README', + settings: 'Paramètres', theme: 'Thème clair / sombre', close: 'Fermer', + languageAria: 'Langage', langAll: 'Tous les langages', + starsAria: 'Étoiles', starsAll: 'Toutes étoiles', + pushedAria: 'Dernier push', pushedAll: 'Toute activité', + pushed1m: 'Push < 1 mois', pushed6m: 'Push < 6 mois', pushed1y: 'Push < 1 an', pushed2y: 'Push < 2 ans', + createdAria: 'Date de création', createdAll: 'Toute date de création', + created1m: 'Créé < 1 mois', created1y: 'Créé < 1 an', created5y: 'Créé < 5 ans', + licenseAria: 'Licence', licenseAll: 'Toute licence', + topicPh: 'topic', topicAria: 'Topic', + goodFirst: 'Good first issues', goodFirstTitle: 'Seulement les dépôts avec des issues « good first issue »', + withForks: 'Forks', withForksTitle: 'Inclure les forks dans les résultats', + withArchived: 'Archivés', withArchivedTitle: 'Inclure les dépôts archivés (lecture seule)', + reset: 'Réinitialiser les filtres', + sortAria: 'Tri', sortBest: 'Pertinence', sortStars: 'Plus d\'étoiles', sortForks: 'Plus de forks', + sortUpdated: 'Mis à jour récemment', sortHelp: 'Issues « help wanted »', + export: 'CSV', exportTitle: 'Télécharger les résultats chargés en CSV', + exportNone: 'Rien à exporter', exportDone: (f) => `Téléchargé : ${f}`, + more: 'Charger la suite', moreN: (n) => `Charger ${n} de plus`, + emptyTitle: 'Aucun dépôt ne correspond.', + emptyHint: 'Assouplissez un filtre ou changez les mots recherchés.', + count: ({ shown, total }) => `${shown} sur ${total} dépôt${total > 1 ? 's' : ''}`, + countCapped: 'GitHub ne renvoie que les 1 000 premiers résultats : affinez la recherche pour voir les autres.', + fork: 'fork', archived: 'archivé', + updated: (s) => `mis à jour ${s}`, + openGh: 'Ouvrir sur GitHub', homepage: 'Site web', copyClone: 'Copier l\'URL de clone', copyName: 'Copier le nom', copied: 'Copié', + filterLang: 'Cliquer pour filtrer sur ce langage', filterTopic: 'Cliquer pour filtrer sur ce topic', + fFull: 'dépôt', fCreated: 'créé', fPushed: 'dernier push', fLicense: 'licence', fIssues: 'issues ouvertes', + fForks: 'forks', fStars: 'étoiles', fClone: 'clone', + noLicense: 'aucune', + searchError: 'Erreur de recherche : ', + unreachable: 'serveur injoignable', + connToken: 'GitHub · jeton', connNoToken: 'GitHub · sans jeton', + quota: ({ r, l }) => `${r}/${l} recherches restantes`, + quotaReset: (s) => `remise à zéro dans ${s} s`, + ghQueryTitle: 'Requête envoyée à GitHub : cliquer pour l\'ouvrir sur github.com', + err_bad_param: 'Valeur de filtre invalide', + err_too_far: 'GitHub ne renvoie que les 1 000 premiers résultats', + err_rate_limited: 'Quota de recherche GitHub épuisé : attendez une minute (ou définissez GITHUB_TOKEN sur le serveur)', + err_bad_query: 'GitHub a refusé la requête', + err_github_token: 'GitHub a refusé le jeton (GITHUB_TOKEN)', + err_github_unreachable: 'GitHub est injoignable', + language: 'Langue', + tabLocale: 'Localisation', tabInterface: 'Interface', + themeTitle: 'Thème', themeSystem: 'Système', themeLight: 'Clair', themeDark: 'Sombre', + themeHelp: 'Système suit la préférence clair/sombre de votre ordinateur ou téléphone.', + listTitle: 'Liste des résultats', density: 'Densité', compact: 'Compacte', comfortable: 'Confortable', + perPage: 'Résultats par page', + tokenTitle: 'Jeton d\'accès', + tokenOn: 'Un jeton GitHub est configuré sur le serveur.', + tokenOff: 'Pas de jeton GitHub : les recherches sont anonymes et limitées à 10 par minute.', + tokenHelp: 'Définissez GITHUB_TOKEN sur le serveur (un jeton « fine-grained » sans aucune permission suffit pour les dépôts publics). Le quota passe à 30 recherches par minute.', + quotaTitle: 'Quota de recherche', + quotaUnknown: 'Inconnu avant la première recherche.', + quotaLine: ({ r, l, s }) => `${r} recherches restantes sur ${l}, remise à zéro dans ${s} s.`, + quotaFull: ({ l }) => `${l} recherches disponibles.`, + quotaHelp: 'Les recherches identiques sont servies par un cache de 5 minutes sur le serveur et ne consomment pas de quota.', + syntaxTitle: 'Syntaxe de recherche', + syntaxHelp: 'Le champ de recherche accepte les qualificatifs GitHub, combinés aux filtres au-dessus de la liste :', + synUser: 'dépôts d\'un utilisateur', synOrg: 'dépôts d\'une organisation', synTopics: 'plusieurs topics à la fois', + synPhrase: 'expression exacte', synNot: 'exclure un mot', synSize: 'taille en Ko', + }, +}; + +/* ================= Utilities ================= */ + +const $ = (sel) => document.querySelector(sel); + +const store = { + get(k, def) { try { const v = localStorage.getItem('searchgit.' + k); return v === null ? def : v; } catch { return def; } }, + set(k, v) { try { localStorage.setItem('searchgit.' + k, v); } catch { /* storage unavailable */ } }, +}; + +const defaultLang = (navigator.language || '').toLowerCase().startsWith('fr') ? 'fr' : 'en'; +let lang = I18N[store.get('lang', defaultLang)] ? store.get('lang', defaultLang) : 'en'; +function t(key, arg) { + const v = I18N[lang][key] ?? I18N.en[key] ?? key; + return typeof v === 'function' ? v(arg) : v; +} + +const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; +const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]); +const debounce = (fn, ms) => { let tm; return (...a) => { clearTimeout(tm); tm = setTimeout(() => fn(...a), ms); }; }; +const fmtNum = (n) => Number(n || 0).toLocaleString(t('locale')); +const fmtCompact = (n) => new Intl.NumberFormat(t('locale'), { notation: 'compact', maximumFractionDigits: 1 }).format(n || 0); +const fmtDate = (s) => new Date(s).toLocaleDateString(t('locale'), { year: 'numeric', month: 'short', day: 'numeric' }); + +function fmtAgo(s) { + const sec = (new Date(s) - Date.now()) / 1000; + const rtf = new Intl.RelativeTimeFormat(t('locale'), { numeric: 'auto' }); + const steps = [[60, 'second'], [3600, 'minute'], [86400, 'hour'], [2592000, 'day'], [31536000, 'month'], [Infinity, 'year']]; + const div = { second: 1, minute: 60, hour: 3600, day: 86400, month: 2592000, year: 31536000 }; + for (const [limit, unit] of steps) { + if (Math.abs(sec) < limit) return rtf.format(Math.round(sec / div[unit]), unit); + } + return ''; +} + +async function api(url) { + const res = await fetch(url); + const text = await res.text(); + let data = null; + try { data = text ? JSON.parse(text) : null; } catch { /* not JSON */ } + if (!res.ok) throw apiError(res, text, data); + return data; +} + +// Known error codes are translated; otherwise the server message is shown. +function apiError(res, text, data) { + let msg = (data && data.error) || text || res.statusText; + if (data && data.code && I18N[lang]['err_' + data.code]) { + msg = t('err_' + data.code) + (data.detail ? (lang === 'fr' ? ' : ' : ': ') + data.detail : ''); + } + return new Error(msg); +} + +function toast(msg) { + const el = $('#toast'); + const dlg = $('#settingsDlg'); + (dlg.open ? dlg : document.body).append(el); + el.textContent = msg; + el.hidden = false; + clearTimeout(toast.tm); + toast.tm = setTimeout(() => { el.hidden = true; }, 1800); +} + +async function copy(text) { + try { await navigator.clipboard.writeText(text); toast(t('copied')); } catch { /* clipboard refused */ } +} + +/* ================= Languages (GitHub linguist colors) ================= */ + +const LANGS = { + 'Go': '#00ADD8', 'Rust': '#dea584', 'Python': '#3572A5', 'JavaScript': '#f1e05a', 'TypeScript': '#3178c6', + 'Java': '#b07219', 'Kotlin': '#A97BFF', 'C': '#555555', 'C++': '#f34b7d', 'C#': '#178600', + 'PHP': '#4F5D95', 'Ruby': '#701516', 'Swift': '#F05138', 'Dart': '#00B4AB', 'Scala': '#c22d40', + 'Elixir': '#6e4a7e', 'Haskell': '#5e5086', 'Lua': '#000080', 'Zig': '#ec915c', 'Nim': '#ffc200', + 'Shell': '#89e051', 'PowerShell': '#012456', 'HTML': '#e34c26', 'CSS': '#663399', 'Vue': '#41b883', + 'Svelte': '#ff3e00', 'Jupyter Notebook': '#DA5B0B', 'R': '#198CE7', 'Julia': '#a270ba', 'OCaml': '#ef7a08', + 'Clojure': '#db5855', 'Erlang': '#B83998', 'Perl': '#0298c3', 'Nix': '#7e7eff', 'HCL': '#844FBA', + 'Dockerfile': '#384d54', 'Vim Script': '#199f4b', 'Emacs Lisp': '#c065db', +}; + +{ + const sel = $('#language'); + for (const name of Object.keys(LANGS).sort((a, b) => a.localeCompare(b))) sel.add(new Option(name, name)); +} + +/* ================= State and filters ================= */ + +const FILTERS = ['q', 'in', 'language', 'stars', 'pushed', 'created', 'license', 'topic', 'sort']; +const TOGGLES = ['goodfirst', 'forks', 'archived']; + +const state = { + items: [], + total: 0, + page: 1, + perPage: Number(store.get('perPage', '30')) || 30, + reqId: 0, + open: new Set(), // fullName of expanded rows + token: null, + rate: null, + query: '', +}; + +function readFilters() { + const f = {}; + for (const k of FILTERS) f[k] = $('#' + k).value.trim(); + for (const k of TOGGLES) f[k] = $('#' + k).getAttribute('aria-pressed') === 'true'; + return f; +} + +function writeFilters(f) { + for (const k of FILTERS) { + const el = $('#' + k); + let v = f[k] || ''; + if (el.tagName === 'SELECT' && v && ![...el.options].some((o) => o.value === v)) { + el.add(new Option(v, v)); // e.g. a language missing from the list + } + el.value = v; + } + for (const k of TOGGLES) $('#' + k).setAttribute('aria-pressed', String(!!f[k])); + markSet(); +} + +// Highlight the filters that are not at their default value. +function markSet() { + let any = false; + for (const k of FILTERS) { + if (k === 'q' || k === 'sort') continue; + const el = $('#' + k); + const on = !!el.value; + el.classList.toggle('set', on); + any ||= on; + } + any ||= TOGGLES.some((k) => $('#' + k).getAttribute('aria-pressed') === 'true'); + $('#resetBtn').hidden = !any; +} + +function params(f, page) { + const p = new URLSearchParams(); + for (const k of FILTERS) if (f[k]) p.set(k, f[k]); + for (const k of TOGGLES) if (f[k]) p.set(k, '1'); + if (page) { + p.set('page', page); + p.set('per_page', state.perPage); + } + return p; +} + +// Filters live in the URL so a search can be bookmarked or shared. +function syncURL(f) { + const qs = params(f).toString(); + history.replaceState(null, '', qs ? '?' + qs : location.pathname); + store.set('filters', qs); +} + +function filtersFromURL() { + const src = location.search ? new URLSearchParams(location.search) : new URLSearchParams(store.get('filters', '')); + const f = {}; + for (const k of FILTERS) f[k] = src.get(k) || ''; + for (const k of TOGGLES) f[k] = src.get(k) === '1'; + return f; +} + +/* ================= Search ================= */ + +async function search(more = false) { + const f = readFilters(); + if (!more) { + syncURL(f); + state.page = 1; + } + const page = more ? state.page + 1 : 1; + const id = ++state.reqId; + document.body.classList.add('busy'); + $('#moreBtn').disabled = true; + try { + const res = await api('/api/search?' + params(f, page)); + if (id !== state.reqId) return; + setError(''); + state.page = page; + state.total = res.total; + state.query = res.query; + if (res.rate && res.rate.limit) state.rate = res.rate; + if (more) { + const known = new Set(state.items.map((r) => r.fullName)); + state.items.push(...res.items.filter((r) => !known.has(r.fullName))); + } else { + state.items = res.items; + state.open.clear(); + window.scrollTo({ top: 0 }); + } + renderList(); + renderStatus(); + } catch (e) { + if (id !== state.reqId) return; + setError(t('searchError') + e.message); + if (!more) { state.items = []; state.total = 0; renderList(); } + } finally { + if (id === state.reqId) document.body.classList.remove('busy'); + $('#moreBtn').disabled = false; + } +} + +function setError(msg) { + $('#error').textContent = msg; + $('#error').hidden = !msg; +} + +/* ================= Rendering ================= */ + +const ICON_STAR = ''; +const ICON_FORK = ''; +const ICON_CLOCK = ''; +const ICON_EXT = ''; + +function rowHTML(r, i) { + const open = state.open.has(r.fullName); + const badges = (r.archived ? `${esc(t('archived'))}` : '') + + (r.fork ? `${esc(t('fork'))}` : ''); + const topics = r.topics.length + ? `
${r.topics.map((x) => ``).join('')}
` + : ''; + const lang = r.language + ? `${esc(r.language)}` + : ''; + return `
+ +
+ + ${r.description ? `

${esc(r.description)}

` : ''} + ${topics} +
+
+ ${lang} + ${ICON_STAR}${fmtCompact(r.stars)} + ${ICON_FORK}${fmtCompact(r.forks)} + ${ICON_CLOCK}${esc(fmtAgo(r.pushedAt))} +
+ ${open ? detailsHTML(r) : ''} +
`; +} + +function detailsHTML(r) { + const clone = r.url + '.git'; + return `
+
+
${esc(t('fFull'))}
${esc(r.fullName)}
+
${esc(t('fStars'))}
${esc(fmtNum(r.stars))}
+
${esc(t('fForks'))}
${esc(fmtNum(r.forks))}
+
${esc(t('fIssues'))}
${esc(fmtNum(r.issues))}
+
${esc(t('fLicense'))}
${esc(r.license || t('noLicense'))}
+
${esc(t('fCreated'))}
${esc(fmtDate(r.createdAt))}
+
${esc(t('fPushed'))}
${esc(fmtDate(r.pushedAt))} (${esc(fmtAgo(r.pushedAt))})
+
${esc(t('fClone'))}
${esc(clone)}
+
+
+ ${ICON_EXT}${esc(t('openGh'))} + ${r.homepage ? `${ICON_EXT}${esc(t('homepage'))}` : ''} + + +
+
`; +} + +function renderList() { + $('#list').innerHTML = state.items.map(rowHTML).join(''); + $('#empty').hidden = state.items.length > 0 || !$('#error').hidden; + const reachable = Math.min(state.total, 1000); + const left = reachable - state.items.length; + $('#more').hidden = left <= 0; + $('#moreBtn').textContent = t('moreN', fmtNum(Math.min(left, state.perPage))); + const c = $('#count'); + c.textContent = state.items.length || state.total ? t('count', { shown: fmtNum(state.items.length), total: fmtNum(state.total) }) : ''; + c.title = state.total > 1000 ? t('countCapped') : ''; +} + +// Last known quota; once the reset time has passed, GitHub has refilled it. +function currentRate() { + const r = state.rate; + if (!r || !r.limit) return null; + const s = Math.round((new Date(r.reset) - Date.now()) / 1000); + return s > 0 ? { ...r, s } : { ...r, remaining: r.limit, s: 0 }; +} + +function renderStatus() { + const conn = $('#conn'); + if (state.token === null) { + conn.className = 'conn ko'; + conn.textContent = t('unreachable'); + } else { + const r = currentRate(); + let txt = t(state.token ? 'connToken' : 'connNoToken'); + let cls = state.token ? 'ok' : 'warn'; + if (r) { + txt += ' · ' + t('quota', { r: r.remaining, l: r.limit }); + if (r.remaining === 0) { cls = 'ko'; txt += ' · ' + t('quotaReset', r.s); } + } + conn.className = 'conn ' + cls; + conn.textContent = txt; + } + const a = $('#ghQuery'); + a.hidden = !state.query; + if (state.query) { + a.textContent = state.query; + a.title = t('ghQueryTitle'); + a.href = 'https://github.com/search?type=repositories&q=' + encodeURIComponent(state.query); + } + renderGitHubSettings(); +} + +async function loadStatus() { + try { + const s = await api('/api/status'); + state.token = s.token; + if (s.rate && s.rate.limit) state.rate = s.rate; + } catch { state.token = null; } + renderStatus(); +} + +/* ================= List interactions ================= */ + +$('#list').addEventListener('click', (ev) => { + const rowEl = ev.target.closest('.row'); + if (!rowEl) return; + const r = state.items[Number(rowEl.dataset.i)]; + const topic = ev.target.closest('[data-topic]'); + if (topic) { + $('#topic').value = topic.dataset.topic; + onFilterChange(); + return; + } + if (ev.target.closest('[data-act="lang"]')) { + writeFilters({ ...readFilters(), language: r.language }); + onFilterChange(); + return; + } + const cp = ev.target.closest('[data-copy]'); + if (cp) { copy(cp.dataset.copy); return; } + if (ev.target.closest('a, .details')) return; + toggleRow(rowEl, r); +}); + +$('#list').addEventListener('keydown', (ev) => { + if (ev.key !== 'Enter' || !ev.target.classList.contains('row')) return; + toggleRow(ev.target, state.items[Number(ev.target.dataset.i)]); +}); + +function toggleRow(rowEl, r) { + if (state.open.has(r.fullName)) state.open.delete(r.fullName); + else state.open.add(r.fullName); + const i = Number(rowEl.dataset.i); + rowEl.outerHTML = rowHTML(r, i); + $(`#list .row[data-i="${i}"]`).focus({ preventScroll: true }); +} + +/* ================= Filter events ================= */ + +const onFilterChange = () => { markSet(); search(); }; +const onTyping = debounce(onFilterChange, 500); // spare the GitHub quota while typing + +for (const k of FILTERS) { + const el = $('#' + k); + el.addEventListener(el.tagName === 'SELECT' ? 'change' : 'input', el.tagName === 'SELECT' ? onFilterChange : onTyping); +} +for (const k of TOGGLES) { + $('#' + k).addEventListener('click', (ev) => { + const b = ev.currentTarget; + b.setAttribute('aria-pressed', String(b.getAttribute('aria-pressed') !== 'true')); + onFilterChange(); + }); +} +$('#searchForm').addEventListener('submit', (ev) => { ev.preventDefault(); onFilterChange(); }); +$('#topic').addEventListener('keydown', (ev) => { if (ev.key === 'Enter') onFilterChange(); }); +$('#resetBtn').addEventListener('click', () => { + const f = readFilters(); + writeFilters({ q: f.q, sort: f.sort }); + onFilterChange(); +}); +$('#moreBtn').addEventListener('click', () => search(true)); + +document.addEventListener('keydown', (ev) => { + if (ev.key === '/' && !ev.target.closest('input, select, textarea') && !$('#settingsDlg').open) { + ev.preventDefault(); + $('#q').focus(); + $('#q').select(); + } +}); + +/* ================= CSV export ================= */ + +$('#exportBtn').addEventListener('click', () => { + if (!state.items.length) { toast(t('exportNone')); return; } + // French Excel expects semicolons. + const sep = lang === 'fr' ? ';' : ','; + const cell = (v) => { + const s = String(v ?? ''); + return /["\n\r;,]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; + }; + const head = ['repository', 'url', 'description', 'language', 'stars', 'forks', 'open_issues', 'license', 'topics', 'archived', 'fork', 'created', 'pushed']; + const lines = [head.join(sep)]; + for (const r of state.items) { + lines.push([r.fullName, r.url, r.description, r.language, r.stars, r.forks, r.issues, r.license, + r.topics.join(' '), r.archived, r.fork, r.createdAt, r.pushedAt].map(cell).join(sep)); + } + const name = 'searchgit-' + new Date().toISOString().slice(0, 10) + '.csv'; + const blob = new Blob(['' + lines.join('\r\n') + '\r\n'], { type: 'text/csv;charset=utf-8' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = name; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + toast(t('exportDone', name)); +}); + +/* ================= Language ================= */ + +function applyLang() { + document.documentElement.lang = lang; + for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n); + for (const el of document.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle); + for (const el of document.querySelectorAll('[data-i18n-aria]')) el.setAttribute('aria-label', t(el.dataset.i18nAria)); + for (const el of document.querySelectorAll('[data-i18n-ph]')) el.placeholder = t(el.dataset.i18nPh); + for (const b of document.querySelectorAll('#langSwitch [data-lang]')) b.setAttribute('aria-checked', String(b.dataset.lang === lang)); +} + +function setLang(next) { + if (next === lang || !I18N[next]) return; + lang = next; + store.set('lang', lang); + applyLang(); + renderList(); + renderStatus(); +} + +$('#langSwitch').addEventListener('click', (ev) => { + const b = ev.target.closest('[data-lang]'); + if (b) setLang(b.dataset.lang); +}); + +/* ================= Theme ================= */ + +function effectiveTheme() { + const th = document.documentElement.dataset.theme; + if (th) return th; + return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} +// mode: 'system' (follow the OS), 'light' or 'dark'. +function setTheme(mode) { + if (mode === 'light' || mode === 'dark') document.documentElement.dataset.theme = mode; + else { delete document.documentElement.dataset.theme; mode = 'system'; } + store.set('theme', mode); + renderInterface(); +} + +$('#themeBtn').addEventListener('click', () => setTheme(effectiveTheme() === 'dark' ? 'light' : 'dark')); +$('#themeSwitch').addEventListener('click', (ev) => { + const b = ev.target.closest('[data-theme-opt]'); + if (b) setTheme(b.dataset.themeOpt); +}); + +/* ================= Interface settings ================= */ + +function applyDensity(d) { + if (d !== 'compact') d = 'comfortable'; + document.documentElement.dataset.density = d; + store.set('density', d); +} + +function renderInterface() { + const mode = document.documentElement.dataset.theme || 'system'; + for (const b of document.querySelectorAll('#themeSwitch [data-theme-opt]')) b.setAttribute('aria-checked', String(b.dataset.themeOpt === mode)); + for (const b of document.querySelectorAll('#densitySwitch [data-density]')) b.setAttribute('aria-checked', String(b.dataset.density === document.documentElement.dataset.density)); + for (const b of document.querySelectorAll('#perPageSwitch [data-per-page]')) b.setAttribute('aria-checked', String(Number(b.dataset.perPage) === state.perPage)); +} + +$('#densitySwitch').addEventListener('click', (ev) => { + const b = ev.target.closest('[data-density]'); + if (!b) return; + applyDensity(b.dataset.density); + renderInterface(); +}); +$('#perPageSwitch').addEventListener('click', (ev) => { + const b = ev.target.closest('[data-per-page]'); + if (!b) return; + state.perPage = Number(b.dataset.perPage); + store.set('perPage', String(state.perPage)); + renderInterface(); + search(); +}); + +function renderGitHubSettings() { + const tok = $('#tokenStatus'); + tok.className = 'state-line ' + (state.token ? 'good' : 'warn'); + tok.textContent = t(state.token ? 'tokenOn' : 'tokenOff'); + const q = $('#quotaStatus'); + const r = currentRate(); + if (r) { + q.className = 'state-line ' + (r.remaining > 0 ? 'good' : 'bad'); + q.textContent = t(r.s ? 'quotaLine' : 'quotaFull', { r: r.remaining, l: r.limit, s: r.s }); + } else { + q.className = 'state-line'; + q.textContent = t('quotaUnknown'); + } +} + +/* ================= Settings dialog ================= */ + +function showSettingsTab(name) { + const tabs = [...document.querySelectorAll('#setNav [data-tab]')]; + if (!tabs.some((b) => b.dataset.tab === name)) name = 'locale'; + for (const b of tabs) b.setAttribute('aria-selected', String(b.dataset.tab === name)); + for (const p of document.querySelectorAll('.set-panel')) p.hidden = p.dataset.panel !== name; + store.set('settingsTab', name); +} +$('#setNav').addEventListener('click', (ev) => { + const b = ev.target.closest('[data-tab]'); + if (b) showSettingsTab(b.dataset.tab); +}); + +$('#settingsBtn').addEventListener('click', () => { + renderInterface(); + loadStatus(); + showSettingsTab(store.get('settingsTab', 'locale')); + $('#settingsDlg').showModal(); +}); +$('#closeSettings').addEventListener('click', () => $('#settingsDlg').close()); +$('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.currentTarget) ev.currentTarget.close(); }); + +/* ================= Startup ================= */ + +applyDensity(store.get('density', 'comfortable')); +applyLang(); +renderInterface(); +writeFilters(filtersFromURL()); +loadStatus(); +search(); +setInterval(() => { if (!document.hidden) renderStatus(); }, 5000); // reset countdown -- 2.54.0