Squelette : recherche de dépôts GitHub avec l'interface de Logstream #1

Merged
claude Bot merged 4 commits from squelette into main 2026-10-01 14:45:20 +02:00
15 changed files with 2175 additions and 2 deletions

No files matched your search

+7
View File
@@ -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=
+3
View File
@@ -25,3 +25,6 @@ go.work.sum
# env file # env file
.env .env
# Binary
/searchgit
+19
View File
@@ -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"]
+69 -2
View File
@@ -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. 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 <http://localhost:8080>.
Without Docker: `go run .` (Go 1.23 or newer), then open <http://localhost:8080>.
## 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).
+89
View File
@@ -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)
}
+12
View File
@@ -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:-}
+249
View File
@@ -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()
}
+98
View File
@@ -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)
}
}
}
+3
View File
@@ -0,0 +1,3 @@
module searchgit
go 1.23
+95
View File
@@ -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)
})
}
+192
View File
@@ -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
}
+52
View File
@@ -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)
}
}
+674
View File
@@ -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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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 = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 3 2.7 5.6 6.1.8-4.5 4.3 1.1 6.1L12 16.9l-5.4 2.9 1.1-6.1-4.5-4.3 6.1-.8z"/></svg>';
const ICON_FORK = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="5" r="2"/><circle cx="18" cy="5" r="2"/><circle cx="12" cy="19" r="2"/><path d="M6 7v2a3 3 0 0 0 3 3h6a3 3 0 0 0 3-3V7M12 12v5"/></svg>';
const ICON_CLOCK = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
const ICON_EXT = '<svg viewBox="0 0 24 24" aria-hidden="true" width="14" height="14"><path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/></svg>';
function rowHTML(r, i) {
const open = state.open.has(r.fullName);
const badges = (r.archived ? `<span class="badge archived">${esc(t('archived'))}</span>` : '')
+ (r.fork ? `<span class="badge">${esc(t('fork'))}</span>` : '');
const topics = r.topics.length
? `<div class="topics">${r.topics.map((x) => `<button type="button" class="topic" data-topic="${esc(x)}" title="${esc(t('filterTopic'))}">${esc(x)}</button>`).join('')}</div>`
: '';
const lang = r.language
? `<span class="lang" data-act="lang" title="${esc(t('filterLang'))}" style="cursor:pointer"><i class="lang-dot" style="--c:${LANGS[r.language] || 'var(--muted)'}"></i>${esc(r.language)}</span>`
: '<span></span>';
return `<div class="row${open ? ' open' : ''}" tabindex="0" data-i="${i}">
<img class="av" src="${esc(r.avatar)}${r.avatar.includes('?') ? '&' : '?'}s=72" alt="" loading="lazy">
<div class="main">
<div class="name"><a href="${esc(r.url)}" target="_blank" rel="noopener"><span class="owner">${esc(r.owner)}/</span>${esc(r.name)}</a>${badges}</div>
${r.description ? `<p class="desc">${esc(r.description)}</p>` : ''}
${topics}
</div>
<div class="meta">
${lang}
<span class="stars" title="${esc(fmtNum(r.stars))} ${esc(t('fStars'))}">${ICON_STAR}${fmtCompact(r.stars)}</span>
<span title="${esc(fmtNum(r.forks))} ${esc(t('fForks'))}">${ICON_FORK}${fmtCompact(r.forks)}</span>
<span title="${esc(t('fPushed'))} ${esc(fmtDate(r.pushedAt))}">${ICON_CLOCK}${esc(fmtAgo(r.pushedAt))}</span>
</div>
${open ? detailsHTML(r) : ''}
</div>`;
}
function detailsHTML(r) {
const clone = r.url + '.git';
return `<div class="details">
<dl>
<dt>${esc(t('fFull'))}</dt><dd>${esc(r.fullName)}</dd>
<dt>${esc(t('fStars'))}</dt><dd>${esc(fmtNum(r.stars))}</dd>
<dt>${esc(t('fForks'))}</dt><dd>${esc(fmtNum(r.forks))}</dd>
<dt>${esc(t('fIssues'))}</dt><dd>${esc(fmtNum(r.issues))}</dd>
<dt>${esc(t('fLicense'))}</dt><dd>${esc(r.license || t('noLicense'))}</dd>
<dt>${esc(t('fCreated'))}</dt><dd>${esc(fmtDate(r.createdAt))}</dd>
<dt>${esc(t('fPushed'))}</dt><dd>${esc(fmtDate(r.pushedAt))} (${esc(fmtAgo(r.pushedAt))})</dd>
<dt>${esc(t('fClone'))}</dt><dd>${esc(clone)}</dd>
</dl>
<div class="acts">
<a class="btn" href="${esc(r.url)}" target="_blank" rel="noopener">${ICON_EXT}${esc(t('openGh'))}</a>
${r.homepage ? `<a class="btn" href="${esc(r.homepage)}" target="_blank" rel="noopener">${ICON_EXT}${esc(t('homepage'))}</a>` : ''}
<button class="btn" type="button" data-copy="${esc(clone)}">${esc(t('copyClone'))}</button>
<button class="btn" type="button" data-copy="${esc(r.fullName)}">${esc(t('copyName'))}</button>
</div>
</div>`;
}
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
+225
View File
@@ -0,0 +1,225 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Searchgit</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236366f1'/%3E%3Ccircle cx='14' cy='14' r='6' fill='none' stroke='white' stroke-width='3'/%3E%3Cpath d='m19 19 5 5' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E">
<link rel="stylesheet" href="style.css">
<script>
// Apply the saved theme and language before first paint to avoid a flash.
try {
var t = localStorage.getItem('searchgit.theme');
if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
var l = localStorage.getItem('searchgit.lang');
if (l) document.documentElement.lang = l;
} catch (e) {}
</script>
</head>
<body>
<header class="topbar">
<div class="brand">
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><circle cx="14" cy="14" r="6"/><path d="m19 19 5 5"/></svg>
<span>Searchgit</span>
</div>
<form id="searchForm" class="search" role="search">
<svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input id="q" type="search" autocomplete="off" spellcheck="false" data-i18n-aria="searchAria" data-i18n-ph="searchPh">
<select id="in" class="chip-select" data-i18n-aria="inAria">
<option value="" data-i18n="inAll">Everywhere</option>
<option value="name" data-i18n="inName">Name</option>
<option value="description" data-i18n="inDesc">Description</option>
<option value="readme" data-i18n="inReadme">README</option>
</select>
</form>
<div class="actions">
<button id="settingsBtn" class="icon-btn" type="button" data-i18n-title="settings" data-i18n-aria="settings">
<!-- Gear icon (Lucide "settings", ISC license) -->
<svg viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button id="themeBtn" class="icon-btn" type="button" data-i18n-title="theme" data-i18n-aria="theme">
<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg class="moon" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
</div>
<div class="progress" aria-hidden="true"></div>
</header>
<section class="filters">
<select id="language" data-i18n-aria="languageAria"><option value="" data-i18n="langAll">All languages</option></select>
<select id="stars" data-i18n-aria="starsAria">
<option value="" data-i18n="starsAll">Any stars</option>
<option value="10">★ ≥ 10</option>
<option value="100">★ ≥ 100</option>
<option value="1000">★ ≥ 1k</option>
<option value="10000">★ ≥ 10k</option>
</select>
<select id="pushed" data-i18n-aria="pushedAria">
<option value="" data-i18n="pushedAll">Any activity</option>
<option value="1m" data-i18n="pushed1m">Pushed &lt; 1 month</option>
<option value="6m" data-i18n="pushed6m">Pushed &lt; 6 months</option>
<option value="1y" data-i18n="pushed1y">Pushed &lt; 1 year</option>
<option value="2y" data-i18n="pushed2y">Pushed &lt; 2 years</option>
</select>
<select id="created" data-i18n-aria="createdAria">
<option value="" data-i18n="createdAll">Any creation date</option>
<option value="1m" data-i18n="created1m">Created &lt; 1 month</option>
<option value="1y" data-i18n="created1y">Created &lt; 1 year</option>
<option value="5y" data-i18n="created5y">Created &lt; 5 years</option>
</select>
<select id="license" data-i18n-aria="licenseAria">
<option value="" data-i18n="licenseAll">Any license</option>
<option value="mit">MIT</option>
<option value="apache-2.0">Apache 2.0</option>
<option value="gpl-3.0">GPL 3.0</option>
<option value="agpl-3.0">AGPL 3.0</option>
<option value="lgpl-3.0">LGPL 3.0</option>
<option value="mpl-2.0">MPL 2.0</option>
<option value="bsd-3-clause">BSD 3-Clause</option>
<option value="bsd-2-clause">BSD 2-Clause</option>
<option value="isc">ISC</option>
<option value="unlicense">Unlicense</option>
</select>
<input id="topic" class="filter-input" type="text" autocomplete="off" spellcheck="false" data-i18n-ph="topicPh" data-i18n-aria="topicAria">
<div class="toggles">
<button id="goodfirst" class="toggle" type="button" aria-pressed="false" data-i18n="goodFirst" data-i18n-title="goodFirstTitle">Good first issues</button>
<button id="forks" class="toggle" type="button" aria-pressed="false" data-i18n="withForks" data-i18n-title="withForksTitle">Forks</button>
<button id="archived" class="toggle" type="button" aria-pressed="false" data-i18n="withArchived" data-i18n-title="withArchivedTitle">Archived</button>
</div>
<button id="resetBtn" class="link-btn" type="button" data-i18n="reset" hidden>Reset</button>
<div class="end">
<span id="count" class="muted"></span>
<select id="sort" data-i18n-aria="sortAria">
<option value="" data-i18n="sortBest">Best match</option>
<option value="stars" data-i18n="sortStars">Most stars</option>
<option value="forks" data-i18n="sortForks">Most forks</option>
<option value="updated" data-i18n="sortUpdated">Recently updated</option>
<option value="help-wanted-issues" data-i18n="sortHelp">Help wanted issues</option>
</select>
<button id="exportBtn" class="btn tool" type="button" data-i18n-title="exportTitle">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12M7 10l5 5 5-5M4 17v3h16v-3"/></svg>
<span class="lbl">CSV</span>
</button>
</div>
</section>
<div id="error" class="error-banner" hidden></div>
<main id="list" class="list"></main>
<div id="more" class="more" hidden>
<button id="moreBtn" class="btn" type="button" data-i18n="more">Load more</button>
</div>
<div id="empty" class="empty" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<p><strong data-i18n="emptyTitle">No matching repositories.</strong></p>
<p class="muted" data-i18n="emptyHint">Loosen a filter or change the search words.</p>
</div>
<footer class="status">
<span id="conn" class="conn"></span>
<a id="ghQuery" class="gh-query" target="_blank" rel="noopener" hidden></a>
</footer>
<dialog id="settingsDlg" class="settings" aria-labelledby="settingsTitle">
<div class="dlg">
<header>
<h2 id="settingsTitle" data-i18n="settings">Settings</h2>
<button id="closeSettings" class="icon-btn" type="button" data-i18n-aria="close">
<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg>
</button>
</header>
<div class="set-body">
<nav id="setNav" class="set-nav" role="tablist" aria-orientation="vertical">
<button type="button" role="tab" data-tab="locale">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>
<span data-i18n="tabLocale">Localization</span>
</button>
<button type="button" role="tab" data-tab="interface">
<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>
<span data-i18n="tabInterface">Interface</span>
</button>
<button type="button" role="tab" data-tab="github">
<svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7M18 10.5c0 4-6 3-11 5.5"/></svg>
<span>GitHub</span>
</button>
</nav>
<div class="set-panels">
<!-- Localization -->
<div class="set-panel" role="tabpanel" data-panel="locale">
<section class="set-section">
<h3 data-i18n="language">Language</h3>
<div id="langSwitch" class="seg" role="radiogroup" data-i18n-aria="language">
<button type="button" role="radio" data-lang="en">English</button>
<button type="button" role="radio" data-lang="fr">Français</button>
</div>
</section>
</div>
<!-- Interface -->
<div class="set-panel" role="tabpanel" data-panel="interface" hidden>
<section class="set-section">
<h3 data-i18n="themeTitle">Theme</h3>
<div id="themeSwitch" class="seg" role="radiogroup" data-i18n-aria="themeTitle">
<button type="button" role="radio" data-theme-opt="system" data-i18n="themeSystem">System</button>
<button type="button" role="radio" data-theme-opt="light" data-i18n="themeLight">Light</button>
<button type="button" role="radio" data-theme-opt="dark" data-i18n="themeDark">Dark</button>
</div>
<p class="muted small hint" data-i18n="themeHelp"></p>
</section>
<section class="set-section">
<h3 data-i18n="listTitle">Result list</h3>
<div class="field-grid">
<span class="lbl" data-i18n="density">Density</span>
<div id="densitySwitch" class="seg" role="radiogroup" data-i18n-aria="density">
<button type="button" role="radio" data-density="compact" data-i18n="compact">Compact</button>
<button type="button" role="radio" data-density="comfortable" data-i18n="comfortable">Comfortable</button>
</div>
<span class="lbl" data-i18n="perPage">Results per page</span>
<div id="perPageSwitch" class="seg" role="radiogroup" data-i18n-aria="perPage">
<button type="button" role="radio" data-per-page="30">30</button>
<button type="button" role="radio" data-per-page="50">50</button>
<button type="button" role="radio" data-per-page="100">100</button>
</div>
</div>
</section>
</div>
<!-- GitHub -->
<div class="set-panel" role="tabpanel" data-panel="github" hidden>
<section class="set-section">
<h3 data-i18n="tokenTitle">Access token</h3>
<p id="tokenStatus" class="state-line"></p>
<p class="muted small" data-i18n="tokenHelp"></p>
</section>
<section class="set-section">
<h3 data-i18n="quotaTitle">Search quota</h3>
<p id="quotaStatus" class="state-line"></p>
<p class="muted small" data-i18n="quotaHelp"></p>
</section>
<section class="set-section">
<h3 data-i18n="syntaxTitle">Search syntax</h3>
<p class="muted small" data-i18n="syntaxHelp"></p>
<ul class="syntax">
<li><code>user:torvalds</code> <span data-i18n="synUser"></span></li>
<li><code>org:golang</code> <span data-i18n="synOrg"></span></li>
<li><code>topic:cli topic:tui</code> <span data-i18n="synTopics"></span></li>
<li><code>"log viewer"</code> <span data-i18n="synPhrase"></span></li>
<li><code>NOT electron</code> <span data-i18n="synNot"></span></li>
<li><code>size:&lt;5000</code> <span data-i18n="synSize"></span></li>
</ul>
</section>
</div>
</div>
</div>
</div>
</dialog>
<div id="toast" class="toast" hidden></div>
<script src="app.js"></script>
</body>
</html>
+388
View File
@@ -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; }
}