Redesign Explorer page: search, topic filter, repo cards
Replaces the plain table with a name filter, clickable topic chips, and repo cards matching Dashboard/repo-settings' visual language, plus a proper empty state.
5 files changed
+114 −30
M
CHANGELOG.md
+4 −0
M
cmd/gitfed-web/handlers_home.go
+65 −16
M
cmd/gitfed-web/render.go
+23 −0
M
internal/i18n/strings_en.go
+11 −7
M
internal/i18n/strings_fr.go
+11 −7
CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## 0.9.4
+
+- Redesigned the Explorer page: a name filter, a topic-chip row (click to filter, click again to clear), and repo cards matching the rest of the app's visual language instead of a plain table. Proper empty state when a filter matches nothing.
+
## 0.9.3
- Fixed inconsistent card widths on repo settings and account settings: forms (visibility/topics, grant collaborator, add SSH key, change password) were capped at 480px while list cards next to them weren't, making the layout look jagged. Only the login form keeps the narrow width now.
cmd/gitfed-web/handlers_home.go
@@ -4,6 +4,8 @@ import (
"bytes"
"html/template"
"net/http"
+ "sort"
+ "strings"
"gitfed/internal/i18n"
"gitfed/internal/store"
@@ -11,24 +13,56 @@ import (
var exploreTpl = newTpl("explore", `
{{.Flash}}
-<h1>{{t .Lang "explore.title"}}</h1>
-{{if .Topic}}<p class="muted">{{t .Lang "explore.filtered_by"}} <span class="badge plain">{{.Topic}}</span> — <a href="/explore">{{t .Lang "explore.clear"}}</a></p>{{end}}
-<table>
-<tr><th>{{t .Lang "explore.col_repo"}}</th><th>{{t .Lang "explore.col_owner"}}</th><th>{{t .Lang "explore.col_topics"}}</th></tr>
-{{range .Repos}}
-<tr>
- <td><a href="/r/{{.Name}}">{{.Name}}</a></td>
- <td class="muted">{{.Owner}}</td>
- <td>{{range .Topics}}<a class="badge plain" href="/explore?topic={{.}}">{{.}}</a>{{end}}</td>
-</tr>
+<div class="gf-page-head"><h1>{{t .Lang "explore.title"}}</h1><span class="count">{{len .Repos}} {{t .Lang "explore.repo_count"}}</span></div>
+<p class="gf-page-sub">{{t .Lang "explore.subtitle"}}</p>
+
+<form class="gf-search" method="get" action="/explore">
+ {{if .Topic}}<input type="hidden" name="topic" value="{{.Topic}}">{{end}}
+ {{icon "search"}}
+ <input type="search" name="q" value="{{.Query}}" placeholder="{{t .Lang "explore.search_placeholder"}}">
+</form>
+
+{{if .AllTopics}}
+<div class="gf-topics-row">
+ <span class="label">{{t .Lang "explore.topics_label"}}</span>
+ {{range .AllTopics}}
+ {{if eq . $.Topic}}
+ <a class="chip active" href="/explore{{if $.Query}}?q={{$.Query}}{{end}}">{{.}} {{icon "close"}}</a>
+ {{else}}
+ <a class="chip" href="/explore?topic={{.}}{{if $.Query}}&q={{$.Query}}{{end}}">{{.}}</a>
+ {{end}}
+ {{end}}
+</div>
+{{end}}
+
+<div class="gf-card">
+{{if .Repos}}
+ <div class="gf-repo-list">
+ {{range .Repos}}
+ <div class="gf-repo-row">
+ <div class="gf-repo-icon">{{icon "folder"}}</div>
+ <div class="gf-repo-main">
+ <div class="name"><a href="/r/{{.Name}}">{{.Name}}</a> <span class="owner">— {{.Owner}}</span></div>
+ {{if .Topics}}<div class="topics">{{range .Topics}}<a href="/explore?topic={{.}}">{{.}}</a>{{end}}</div>{{end}}
+ </div>
+ <span class="badge trusted">{{t $.Lang "common.public"}}</span>
+ </div>
+ {{end}}
+ </div>
{{else}}
-<tr><td colspan="3" class="muted">{{t .Lang "explore.empty"}}</td></tr>
+ <div class="gf-empty">
+ {{icon "search"}}
+ <p><strong>{{t .Lang "explore.empty_title"}}</strong></p>
+ <p class="muted">{{t .Lang "explore.empty_hint"}}</p>
+ <a class="clear" href="/explore">{{t .Lang "explore.clear"}} →</a>
+ </div>
{{end}}
-</table>
+</div>
`)
func (s *server) handleExplore(w http.ResponseWriter, r *http.Request) {
topic := r.URL.Query().Get("topic")
+ query := strings.TrimSpace(r.URL.Query().Get("q"))
lang := s.lang(r)
repos, err := s.ops.ListRepos()
@@ -36,23 +70,38 @@ func (s *server) handleExplore(w http.ResponseWriter, r *http.Request) {
s.serverError(w, r, err)
return
}
+
+ topicSet := map[string]bool{}
var visible []store.Repo
+ needle := strings.ToLower(query)
for _, repo := range repos {
if !repo.Public {
continue
}
+ for _, t := range repo.Topics {
+ topicSet[t] = true
+ }
if topic != "" && !containsTopic(repo.Topics, topic) {
continue
}
+ if needle != "" && !strings.Contains(strings.ToLower(repo.Name), needle) {
+ continue
+ }
visible = append(visible, repo)
}
+ allTopics := make([]string, 0, len(topicSet))
+ for t := range topicSet {
+ allTopics = append(allTopics, t)
+ }
+ sort.Strings(allTopics)
var buf bytes.Buffer
_ = exploreTpl.Execute(&buf, struct {
- Repos []store.Repo
- Topic, Lang string
- Flash template.HTML
- }{visible, topic, string(lang), flash(r)})
+ Repos []store.Repo
+ AllTopics []string
+ Topic, Query, Lang string
+ Flash template.HTML
+ }{visible, allTopics, topic, query, string(lang), flash(r)})
s.render(w, r, i18n.T(lang, "explore.title"), "explore", template.HTML(buf.String()))
}
cmd/gitfed-web/render.go
@@ -139,6 +139,7 @@ const iconSprite = `<svg width="0" height="0" style="position:absolute" aria-hid
<symbol id="ic-sliders" viewBox="0 0 24 24"><path d="M5 6h9M17.5 6H19M5 12h5.5M13 12H19M5 18h9M17.5 18H19" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="12" cy="6" r="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="9.5" cy="12" r="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="15.5" cy="18" r="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/></symbol>
<symbol id="ic-arrow" viewBox="0 0 24 24"><path d="M4 12h14.5M13 6.5l6 5.5-6 5.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="ic-close" viewBox="0 0 24 24"><path d="M5.5 5.5l13 13M18.5 5.5l-13 13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol>
+<symbol id="ic-search" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="7" fill="none" stroke="currentColor" stroke-width="1.6"/><line x1="15.8" y1="15.8" x2="20.5" y2="20.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol>
<symbol id="ic-bell" viewBox="0 0 24 24"><path d="M6 10.5c0-3.3 2.7-6 6-6s6 2.7 6 6v3.3l1.6 2.7H4.4L6 13.8V10.5Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M9.8 18.5a2.3 2.3 0 0 0 4.4 0" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="ic-history" viewBox="0 0 24 24"><circle cx="12" cy="12.5" r="8" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M12 8v4.7l3.3 2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 3.3 5 6M16 3.3 19 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="ic-user" viewBox="0 0 24 24"><circle cx="12" cy="8.3" r="3.3" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M4.8 19c1-3.2 3.9-5 7.2-5s6.2 1.8 7.2 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
@@ -353,6 +354,7 @@ const shellHeadSrc = `<!doctype html>
/* ---------- dashboard / settings / admin ---------- */
.gf-page-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; }
.gf-page-head h1 { font-size: 1.35rem; margin: 0; }
+ .gf-page-head .count { font-family: var(--mono); font-size: 0.82rem; color: var(--text-faint); }
.gf-stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 1.25rem; }
.gf-stat { background: var(--surface); padding: 1rem 1.1rem; }
@@ -368,6 +370,10 @@ const shellHeadSrc = `<!doctype html>
.gf-repo-main .name { font-family: var(--mono); font-size: 0.92rem; font-weight: 600; }
.gf-repo-main .name a:hover { color: var(--accent); }
.gf-repo-main .meta { font-size: 0.78rem; color: var(--text-faint); margin-top: 0.15rem; }
+ .gf-repo-main .owner { color: var(--text-faint); font-weight: 400; }
+ .gf-repo-main .topics { margin-top: 0.35rem; display: flex; gap: 0.35rem; flex-wrap: wrap; }
+ .gf-repo-main .topics a { font-family: var(--mono); font-size: 0.72rem; padding: 0.1rem 0.5rem; border-radius: 999px; background: var(--surface-3); color: var(--text-faint); text-decoration: none; }
+ .gf-repo-main .topics a:hover { color: var(--text-dim); }
.gf-commit-list { display: flex; flex-direction: column; }
.gf-commit-row { display: flex; align-items: center; gap: 0.9rem; padding: 0.8rem 1.1rem; border-bottom: 1px solid var(--border); }
@@ -382,6 +388,23 @@ const shellHeadSrc = `<!doctype html>
.gf-new-repo summary::-webkit-details-marker { display: none; }
.gf-new-repo[open] summary { margin-bottom: 0.5rem; }
+ .gf-page-sub { color: var(--text-dim); font-size: 0.9rem; margin: -0.6rem 0 1.1rem; }
+ .gf-search { display: flex; align-items: center; gap: 0.6rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 0.55rem 0.8rem; margin-bottom: 1.1rem; color: var(--text-faint); }
+ .gf-search input { border: none; background: none; color: var(--text); font-size: 0.9rem; flex: 1; font-family: inherit; }
+ .gf-search input:focus { outline: none; }
+ .gf-search input::placeholder { color: var(--text-faint); }
+
+ .gf-topics-row { display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap; margin-bottom: 1.4rem; }
+ .gf-topics-row .label { font-size: 0.76rem; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.04em; margin-right: 0.3rem; }
+ .chip { display: inline-flex; align-items: center; gap: 0.3rem; font-family: var(--mono); font-size: 0.76rem; padding: 0.28rem 0.65rem; border-radius: 999px; background: var(--surface-2); border: 1px solid var(--border); color: var(--text-dim); text-decoration: none; }
+ .chip:hover { border-color: var(--border-strong); color: var(--text); }
+ .chip.active { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 700; }
+
+ .gf-empty { padding: 2.2rem 1.5rem; text-align: center; }
+ .gf-empty .icon { width: 30px; height: 30px; color: var(--text-faint); margin-bottom: 0.7rem; }
+ .gf-empty p { margin: 0.2rem 0; }
+ .gf-empty .clear { color: var(--accent); font-size: 0.86rem; margin-top: 0.6rem; display: inline-block; }
+
.gf-tabs { display: flex; gap: 0.3rem; border-bottom: 1px solid var(--border); margin-bottom: 1.25rem; }
.gf-tabs button { margin: 0; padding: 0.6rem 0.9rem; font-size: 0.86rem; color: var(--text-dim); border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; background: none; border-radius: 0; cursor: pointer; font-family: inherit; }
.gf-tabs button:hover { background: none; color: var(--text); }
internal/i18n/strings_en.go
@@ -16,13 +16,17 @@ var en = map[string]string{
"footer.changelog": "gitfed %s",
// ---------- explore ----------
- "explore.title": "Explore public repositories",
- "explore.filtered_by": "Filtered by topic",
- "explore.clear": "clear",
- "explore.col_repo": "Repo",
- "explore.col_owner": "Owner",
- "explore.col_topics": "Topics",
- "explore.empty": "No public repositories yet.",
+ "explore.title": "Explore public repositories",
+ "explore.subtitle": "Public repos on this instance, readable by anyone — clonable over HTTPS with no account.",
+ "explore.repo_count": "public repos",
+ "explore.search_placeholder": "Filter by name…",
+ "explore.topics_label": "Topics",
+ "explore.clear": "clear",
+ "explore.col_repo": "Repo",
+ "explore.col_owner": "Owner",
+ "explore.col_topics": "Topics",
+ "explore.empty_title": "No public repositories match",
+ "explore.empty_hint": "Try a different topic or search, or clear the filter.",
// ---------- landing ----------
"landing.title": "gitfed",
internal/i18n/strings_fr.go
@@ -16,13 +16,17 @@ var fr = map[string]string{
"footer.changelog": "gitfed %s",
// ---------- explore ----------
- "explore.title": "Explorer les dépôts publics",
- "explore.filtered_by": "Filtré par sujet",
- "explore.clear": "effacer",
- "explore.col_repo": "Dépôt",
- "explore.col_owner": "Propriétaire",
- "explore.col_topics": "Sujets",
- "explore.empty": "Aucun dépôt public pour le moment.",
+ "explore.title": "Explorer les dépôts publics",
+ "explore.subtitle": "Dépôts publics de cette instance, lisibles par tout le monde — clonables en HTTPS sans compte.",
+ "explore.repo_count": "dépôts publics",
+ "explore.search_placeholder": "Filtrer par nom…",
+ "explore.topics_label": "Sujets",
+ "explore.clear": "effacer",
+ "explore.col_repo": "Dépôt",
+ "explore.col_owner": "Propriétaire",
+ "explore.col_topics": "Sujets",
+ "explore.empty_title": "Aucun dépôt public ne correspond",
+ "explore.empty_hint": "Essayez un autre sujet ou une autre recherche, ou effacez le filtre.",
// ---------- landing ----------
"landing.title": "gitfed",