Merge pull request 'Squelette : recherche de dépôts GitHub avec l'interface de Logstream' (#1) from squelette into main
This commit was merged in pull request #1.
This commit is contained in:
commit
8f4048d046
15 files changed
+2175
-2
No files matched your search
@@ -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=
|
||||
@@ -25,3 +25,6 @@ go.work.sum
|
||||
# env file
|
||||
.env
|
||||
|
||||
# Binary
|
||||
/searchgit
|
||||
|
||||
+19
@@ -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"]
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:-}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 = '<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
@@ -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 < 1 month</option>
|
||||
<option value="6m" data-i18n="pushed6m">Pushed < 6 months</option>
|
||||
<option value="1y" data-i18n="pushed1y">Pushed < 1 year</option>
|
||||
<option value="2y" data-i18n="pushed2y">Pushed < 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 < 1 month</option>
|
||||
<option value="1y" data-i18n="created1y">Created < 1 year</option>
|
||||
<option value="5y" data-i18n="created5y">Created < 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:<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
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user