tenseleyflow/shithub / 5ad7821

Browse files

Align search results UI

Authored by espadonne
SHA
5ad7821416da0632cc1b17dd0a2addb535e09b0f
Parents
128ce28
Tree
e6d38c5

6 changed files

StatusFile+-
M internal/web/handlers/search/search.go 108 7
A internal/web/handlers/search/search_test.go 30 0
M internal/web/render/octicons.go 2 0
M internal/web/static/css/shithub.css 319 15
M internal/web/templates/_nav.html 3 1
M internal/web/templates/search/results.html 154 83
internal/web/handlers/search/search.gomodified
@@ -9,6 +9,7 @@ import (
9
 	"errors"
9
 	"errors"
10
 	"log/slog"
10
 	"log/slog"
11
 	"net/http"
11
 	"net/http"
12
+	"net/url"
12
 
13
 
13
 	"github.com/go-chi/chi/v5"
14
 	"github.com/go-chi/chi/v5"
14
 	"github.com/jackc/pgx/v5/pgxpool"
15
 	"github.com/jackc/pgx/v5/pgxpool"
@@ -67,6 +68,9 @@ func (h *Handlers) results(w http.ResponseWriter, r *http.Request) {
67
 	if tab == "" {
68
 	if tab == "" {
68
 		tab = "repos"
69
 		tab = "repos"
69
 	}
70
 	}
71
+	if !validSearchTab(tab) {
72
+		tab = "repos"
73
+	}
70
 	page := pageFromRequest(r)
74
 	page := pageFromRequest(r)
71
 
75
 
72
 	parsed := srch.ParseQuery(rawQ)
76
 	parsed := srch.ParseQuery(rawQ)
@@ -74,16 +78,18 @@ func (h *Handlers) results(w http.ResponseWriter, r *http.Request) {
74
 	deps := h.deps()
78
 	deps := h.deps()
75
 
79
 
76
 	data := map[string]any{
80
 	data := map[string]any{
77
-		"Title":    "Search",
81
+		"Title":             "Search",
78
-		"Query":    rawQ,
82
+		"Query":             rawQ,
79
-		"Tab":      tab,
83
+		"GlobalSearchQuery": rawQ,
80
-		"Page":     page,
84
+		"Tab":               tab,
81
-		"Parsed":   parsed,
85
+		"Page":              page,
82
-		"PageSize": srch.PageSize,
86
+		"Parsed":            parsed,
87
+		"PageSize":          srch.PageSize,
83
 	}
88
 	}
84
 
89
 
85
 	if !parsed.HasContent() {
90
 	if !parsed.HasContent() {
86
 		data["EmptyQuery"] = true
91
 		data["EmptyQuery"] = true
92
+		data["SearchTabs"] = h.searchTabs(r, actor, parsed, rawQ, tab)
87
 		_ = h.d.Render.RenderPage(w, r, "search/results", data)
93
 		_ = h.d.Render.RenderPage(w, r, "search/results", data)
88
 		return
94
 		return
89
 	}
95
 	}
@@ -106,6 +112,14 @@ func (h *Handlers) results(w http.ResponseWriter, r *http.Request) {
106
 		data["Issues"] = rows
112
 		data["Issues"] = rows
107
 		data["Total"] = total
113
 		data["Total"] = total
108
 		data["HasNext"] = int64(page*srch.PageSize) < total
114
 		data["HasNext"] = int64(page*srch.PageSize) < total
115
+	case "pulls":
116
+		rows, total, err := srch.SearchIssues(r.Context(), deps, actor, parsed, "pr", srch.PageSize, offset)
117
+		if err != nil && !errors.Is(err, srch.ErrEmptyQuery) {
118
+			h.d.Logger.ErrorContext(r.Context(), "search pulls", "error", err)
119
+		}
120
+		data["Issues"] = rows
121
+		data["Total"] = total
122
+		data["HasNext"] = int64(page*srch.PageSize) < total
109
 	case "users":
123
 	case "users":
110
 		rows, total, err := srch.SearchUsers(r.Context(), deps, parsed, srch.PageSize, offset)
124
 		rows, total, err := srch.SearchUsers(r.Context(), deps, parsed, srch.PageSize, offset)
111
 		if err != nil && !errors.Is(err, srch.ErrEmptyQuery) {
125
 		if err != nil && !errors.Is(err, srch.ErrEmptyQuery) {
@@ -129,12 +143,82 @@ func (h *Handlers) results(w http.ResponseWriter, r *http.Request) {
129
 		data["EmptyQuery"] = true
143
 		data["EmptyQuery"] = true
130
 	}
144
 	}
131
 	data["HasPrev"] = page > 1
145
 	data["HasPrev"] = page > 1
146
+	data["SearchTabs"] = h.searchTabs(r, actor, parsed, rawQ, tab)
132
 
147
 
133
 	if err := h.d.Render.RenderPage(w, r, "search/results", data); err != nil {
148
 	if err := h.d.Render.RenderPage(w, r, "search/results", data); err != nil {
134
 		h.d.Logger.ErrorContext(r.Context(), "search render", "error", err)
149
 		h.d.Logger.ErrorContext(r.Context(), "search render", "error", err)
135
 	}
150
 	}
136
 }
151
 }
137
 
152
 
153
+func validSearchTab(tab string) bool {
154
+	switch tab {
155
+	case "repos", "issues", "pulls", "users", "code":
156
+		return true
157
+	default:
158
+		return false
159
+	}
160
+}
161
+
162
+type searchTab struct {
163
+	Key      string
164
+	Label    string
165
+	Icon     string
166
+	Count    int64
167
+	Href     string
168
+	Selected bool
169
+}
170
+
171
+func (h *Handlers) searchTabs(r *http.Request, actor policy.Actor, parsed srch.ParsedQuery, rawQ, active string) []searchTab {
172
+	tabs := []searchTab{
173
+		{Key: "repos", Label: "Repositories", Icon: "repo"},
174
+		{Key: "code", Label: "Code", Icon: "code"},
175
+		{Key: "issues", Label: "Issues", Icon: "issue-opened"},
176
+		{Key: "pulls", Label: "Pull requests", Icon: "git-pull-request"},
177
+		{Key: "users", Label: "Users", Icon: "person"},
178
+	}
179
+	for i := range tabs {
180
+		tabs[i].Selected = tabs[i].Key == active
181
+		tabs[i].Href = searchHref(rawQ, tabs[i].Key, 1)
182
+	}
183
+	if !parsed.HasContent() {
184
+		return tabs
185
+	}
186
+
187
+	deps := h.deps()
188
+	for i := range tabs {
189
+		var total int64
190
+		var err error
191
+		switch tabs[i].Key {
192
+		case "repos":
193
+			_, total, err = srch.SearchRepos(r.Context(), deps, actor, parsed, 0, 0)
194
+		case "code":
195
+			_, total, err = srch.SearchCode(r.Context(), deps, actor, parsed, 0, 0)
196
+		case "issues":
197
+			_, total, err = srch.SearchIssues(r.Context(), deps, actor, parsed, "issue", 0, 0)
198
+		case "pulls":
199
+			_, total, err = srch.SearchIssues(r.Context(), deps, actor, parsed, "pr", 0, 0)
200
+		case "users":
201
+			_, total, err = srch.SearchUsers(r.Context(), deps, parsed, 0, 0)
202
+		}
203
+		if err != nil && !errors.Is(err, srch.ErrEmptyQuery) {
204
+			h.d.Logger.ErrorContext(r.Context(), "search tab count", "tab", tabs[i].Key, "error", err)
205
+			continue
206
+		}
207
+		tabs[i].Count = total
208
+	}
209
+	return tabs
210
+}
211
+
212
+func searchHref(q, tab string, page int) string {
213
+	v := url.Values{}
214
+	v.Set("q", q)
215
+	v.Set("type", tab)
216
+	if page > 1 {
217
+		v.Set("p", intString(page))
218
+	}
219
+	return "/search?" + v.Encode()
220
+}
221
+
138
 // quick is the htmx dropdown endpoint. Returns one fragment with
222
 // quick is the htmx dropdown endpoint. Returns one fragment with
139
 // the top N results across all four types stacked vertically.
223
 // the top N results across all four types stacked vertically.
140
 func (h *Handlers) quick(w http.ResponseWriter, r *http.Request) {
224
 func (h *Handlers) quick(w http.ResponseWriter, r *http.Request) {
@@ -164,7 +248,10 @@ func (h *Handlers) quick(w http.ResponseWriter, r *http.Request) {
164
 
248
 
165
 // pageFromRequest pulls ?page=N, defaulting to 1 on missing/invalid.
249
 // pageFromRequest pulls ?page=N, defaulting to 1 on missing/invalid.
166
 func pageFromRequest(r *http.Request) int {
250
 func pageFromRequest(r *http.Request) int {
167
-	p := r.URL.Query().Get("page")
251
+	p := r.URL.Query().Get("p")
252
+	if p == "" {
253
+		p = r.URL.Query().Get("page")
254
+	}
168
 	if p == "" {
255
 	if p == "" {
169
 		return 1
256
 		return 1
170
 	}
257
 	}
@@ -183,3 +270,17 @@ func pageFromRequest(r *http.Request) int {
183
 	}
270
 	}
184
 	return n
271
 	return n
185
 }
272
 }
273
+
274
+func intString(n int) string {
275
+	if n == 0 {
276
+		return "0"
277
+	}
278
+	var buf [20]byte
279
+	i := len(buf)
280
+	for n > 0 {
281
+		i--
282
+		buf[i] = byte('0' + n%10)
283
+		n /= 10
284
+	}
285
+	return string(buf[i:])
286
+}
internal/web/handlers/search/search_test.goadded
@@ -0,0 +1,30 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package search
4
+
5
+import (
6
+	"net/http/httptest"
7
+	"testing"
8
+)
9
+
10
+func TestPageFromRequestUsesGitHubStylePParam(t *testing.T) {
11
+	req := httptest.NewRequest("GET", "/search?q=repo&p=3", nil)
12
+	if got := pageFromRequest(req); got != 3 {
13
+		t.Fatalf("pageFromRequest = %d, want 3", got)
14
+	}
15
+}
16
+
17
+func TestPageFromRequestAcceptsLegacyPageParam(t *testing.T) {
18
+	req := httptest.NewRequest("GET", "/search?q=repo&page=2", nil)
19
+	if got := pageFromRequest(req); got != 2 {
20
+		t.Fatalf("pageFromRequest = %d, want 2", got)
21
+	}
22
+}
23
+
24
+func TestSearchHrefEscapesQuery(t *testing.T) {
25
+	got := searchHref("repo:alice/demo pub", "issues", 4)
26
+	want := "/search?p=4&q=repo%3Aalice%2Fdemo+pub&type=issues"
27
+	if got != want {
28
+		t.Fatalf("searchHref = %q, want %q", got, want)
29
+	}
30
+}
internal/web/render/octicons.gomodified
@@ -31,6 +31,8 @@ func BuiltinOcticons() OcticonResolver {
31
 			`><path d="M9.598 1.591a.75.75 0 0 1 .785-.175 7 7 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786z"/></svg>`),
31
 			`><path d="M9.598 1.591a.75.75 0 0 1 .785-.175 7 7 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786z"/></svg>`),
32
 		"alert": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
32
 		"alert": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
33
 			`><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575zM8 5a.75.75 0 0 0-.75.75v2.5a.75.75 0 0 0 1.5 0v-2.5A.75.75 0 0 0 8 5zm1 6a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/></svg>`),
33
 			`><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575zM8 5a.75.75 0 0 0-.75.75v2.5a.75.75 0 0 0 1.5 0v-2.5A.75.75 0 0 0 8 5zm1 6a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/></svg>`),
34
+		"search": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
35
+			`><path d="M10.68 11.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.749.749 0 1 1-1.06 1.06ZM6.5 11.5a5 5 0 1 0 0-10 5 5 0 0 0 0 10Z"/></svg>`),
34
 		"repo": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
36
 		"repo": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
35
 			`><path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75A.75.75 0 0 1 14 .75v12.5a.75.75 0 0 1-.75.75H4.5a1 1 0 0 0 0 2h8.75a.75.75 0 0 1 0 1.5H4.5A2.5 2.5 0 0 1 2 15V2.5Zm2.5-1A1 1 0 0 0 3.5 2.5v10.21c.31-.13.648-.21 1-.21h8V1.5Z"/></svg>`),
37
 			`><path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75A.75.75 0 0 1 14 .75v12.5a.75.75 0 0 1-.75.75H4.5a1 1 0 0 0 0 2h8.75a.75.75 0 0 1 0 1.5H4.5A2.5 2.5 0 0 1 2 15V2.5Zm2.5-1A1 1 0 0 0 3.5 2.5v10.21c.31-.13.648-.21 1-.21h8V1.5Z"/></svg>`),
36
 		"code": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
38
 		"code": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
internal/web/static/css/shithub.cssmodified
@@ -206,6 +206,8 @@ code {
206
 .shithub-button {
206
 .shithub-button {
207
   display: inline-flex;
207
   display: inline-flex;
208
   align-items: center;
208
   align-items: center;
209
+  justify-content: center;
210
+  gap: 0.35rem;
209
   padding: 0.4rem 0.85rem;
211
   padding: 0.4rem 0.85rem;
210
   border-radius: 6px;
212
   border-radius: 6px;
211
   font-size: 0.875rem;
213
   font-size: 0.875rem;
@@ -213,6 +215,10 @@ code {
213
   border: 1px solid transparent;
215
   border: 1px solid transparent;
214
   cursor: pointer;
216
   cursor: pointer;
215
 }
217
 }
218
+.shithub-button-small {
219
+  padding: 0.25rem 0.7rem;
220
+  font-size: 0.75rem;
221
+}
216
 .shithub-button-ghost {
222
 .shithub-button-ghost {
217
   color: var(--fg-default);
223
   color: var(--fg-default);
218
   border-color: var(--border-default);
224
   border-color: var(--border-default);
@@ -1947,34 +1953,293 @@ code {
1947
 
1953
 
1948
 /* S28 — search */
1954
 /* S28 — search */
1949
 .shithub-nav-search {
1955
 .shithub-nav-search {
1950
-  flex: 1;
1956
+  position: relative;
1957
+  flex: 1 1 22rem;
1951
   display: flex;
1958
   display: flex;
1959
+  align-items: center;
1952
   max-width: 32rem;
1960
   max-width: 32rem;
1953
   margin: 0 1rem;
1961
   margin: 0 1rem;
1954
 }
1962
 }
1963
+.shithub-nav-search-icon {
1964
+  position: absolute;
1965
+  left: 0.65rem;
1966
+  top: 50%;
1967
+  transform: translateY(-50%);
1968
+  color: var(--fg-muted);
1969
+  line-height: 0;
1970
+  pointer-events: none;
1971
+}
1955
 .shithub-nav-search input {
1972
 .shithub-nav-search input {
1956
   width: 100%;
1973
   width: 100%;
1957
-  padding: 0.4rem 0.7rem;
1974
+  min-height: 2rem;
1975
+  padding: 0.35rem 2rem 0.35rem 2rem;
1958
   border: 1px solid var(--border-default);
1976
   border: 1px solid var(--border-default);
1959
   border-radius: 6px;
1977
   border-radius: 6px;
1960
-  background: var(--canvas-subtle);
1978
+  background: var(--canvas-default);
1961
   color: var(--fg-default);
1979
   color: var(--fg-default);
1962
   font-size: 0.85rem;
1980
   font-size: 0.85rem;
1963
 }
1981
 }
1964
-.shithub-search { padding: 1rem 0; }
1982
+.shithub-nav-search-key {
1965
-.shithub-search-head { padding-bottom: 0.5rem; border-bottom: 1px solid var(--border-default); margin-bottom: 1rem; }
1983
+  position: absolute;
1966
-.shithub-search-form { display: flex; gap: 0.5rem; margin: 0.5rem 0; }
1984
+  right: 0.55rem;
1967
-.shithub-search-form input[type=text] { flex: 1; padding: 0.5rem 0.7rem; }
1985
+  top: 50%;
1968
-.shithub-search-tabs { display: flex; gap: 0.5rem; margin: 0.5rem 0; }
1986
+  transform: translateY(-50%);
1969
-.shithub-search-list { list-style: none; padding: 0; margin: 0.5rem 0; }
1987
+  min-width: 1rem;
1970
-.shithub-search-list li {
1988
+  padding: 0 0.25rem;
1971
-  display: flex; gap: 0.5rem; align-items: baseline;
1989
+  border: 1px solid var(--border-default);
1972
-  flex-wrap: wrap;
1990
+  border-radius: 4px;
1973
-  padding: 0.5rem 0;
1991
+  color: var(--fg-muted);
1992
+  font-size: 0.75rem;
1993
+  line-height: 1.15rem;
1994
+  text-align: center;
1995
+  pointer-events: none;
1996
+}
1997
+.shithub-search-page {
1998
+  padding: 1.5rem 1rem 3rem;
1999
+}
2000
+.shithub-search-shell {
2001
+  display: grid;
2002
+  grid-template-columns: 220px minmax(0, 1fr);
2003
+  gap: 2rem;
2004
+  max-width: 1280px;
2005
+  margin: 0 auto;
2006
+}
2007
+.shithub-search-sidebar {
2008
+  padding-top: 3.35rem;
2009
+}
2010
+.shithub-search-sidebar h2 {
2011
+  margin: 0 0 0.55rem;
2012
+  font-size: 0.875rem;
2013
+  font-weight: 600;
2014
+}
2015
+.shithub-search-filter-list {
2016
+  display: flex;
2017
+  flex-direction: column;
2018
+}
2019
+.shithub-search-filter {
2020
+  display: flex;
2021
+  align-items: center;
2022
+  justify-content: space-between;
2023
+  gap: 0.75rem;
2024
+  min-height: 2.25rem;
2025
+  padding: 0.4rem 0.65rem;
2026
+  border-radius: 6px;
2027
+  color: var(--fg-default);
2028
+  font-size: 0.875rem;
2029
+}
2030
+.shithub-search-filter:hover {
2031
+  background: var(--canvas-subtle);
2032
+  text-decoration: none;
2033
+}
2034
+.shithub-search-filter.is-selected {
2035
+  background: var(--canvas-subtle);
2036
+  font-weight: 600;
2037
+}
2038
+.shithub-search-filter.is-selected::before {
2039
+  content: "";
2040
+  width: 4px;
2041
+  align-self: stretch;
2042
+  margin: -0.4rem 0 -0.4rem -0.65rem;
2043
+  border-radius: 6px 0 0 6px;
2044
+  background: var(--accent-emphasis);
2045
+}
2046
+.shithub-search-filter-label {
2047
+  display: inline-flex;
2048
+  align-items: center;
2049
+  gap: 0.45rem;
2050
+  min-width: 0;
2051
+}
2052
+.shithub-search-filter-label svg {
2053
+  color: var(--fg-muted);
2054
+  flex: 0 0 auto;
2055
+}
2056
+.shithub-search-filter-count {
2057
+  color: var(--fg-muted);
2058
+  font-size: 0.75rem;
2059
+}
2060
+.shithub-search-results {
2061
+  min-width: 0;
2062
+}
2063
+.shithub-search-query-form {
2064
+  display: flex;
2065
+  gap: 0.5rem;
2066
+  margin-bottom: 1.25rem;
2067
+}
2068
+.shithub-search-query-form input[type=text] {
2069
+  flex: 1;
2070
+  min-height: 2.35rem;
2071
+  padding: 0.45rem 0.75rem;
2072
+  border: 1px solid var(--border-default);
2073
+  border-radius: 6px;
2074
+  background: var(--canvas-default);
2075
+  color: var(--fg-default);
2076
+  font-size: 0.875rem;
2077
+}
2078
+.shithub-search-results-head {
2079
+  display: flex;
2080
+  align-items: center;
2081
+  justify-content: space-between;
2082
+  gap: 1rem;
2083
+  padding-bottom: 0.9rem;
2084
+  border-bottom: 1px solid var(--border-default);
2085
+}
2086
+.shithub-search-results-head h1 {
2087
+  margin: 0;
2088
+  font-size: 1.25rem;
2089
+  line-height: 1.3;
2090
+}
2091
+.shithub-search-sort {
2092
+  position: relative;
2093
+  flex: 0 0 auto;
2094
+}
2095
+.shithub-search-sort > summary {
2096
+  list-style: none;
2097
+  display: inline-flex;
2098
+  align-items: center;
2099
+  min-height: 2rem;
2100
+  padding: 0.35rem 0.75rem;
2101
+  border: 1px solid var(--border-default);
2102
+  border-radius: 6px;
2103
+  background: var(--canvas-subtle);
2104
+  color: var(--fg-default);
2105
+  cursor: pointer;
2106
+  font-size: 0.875rem;
2107
+  font-weight: 500;
2108
+}
2109
+.shithub-search-sort > summary::-webkit-details-marker { display: none; }
2110
+.shithub-search-sort > div {
2111
+  position: absolute;
2112
+  right: 0;
2113
+  top: calc(100% + 0.4rem);
2114
+  z-index: 20;
2115
+  min-width: 190px;
2116
+  padding: 0.35rem 0;
2117
+  border: 1px solid var(--border-default);
2118
+  border-radius: 6px;
2119
+  background: var(--canvas-default);
2120
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
2121
+}
2122
+.shithub-search-sort span {
2123
+  display: block;
2124
+  padding: 0.45rem 0.75rem;
2125
+  font-size: 0.875rem;
2126
+}
2127
+.shithub-search-result-list {
2128
+  list-style: none;
2129
+  padding: 0;
2130
+  margin: 0;
2131
+}
2132
+.shithub-search-result {
2133
+  display: flex;
2134
+  gap: 0.75rem;
2135
+  justify-content: space-between;
2136
+  padding: 1rem 0;
1974
   border-bottom: 1px solid var(--border-default);
2137
   border-bottom: 1px solid var(--border-default);
1975
 }
2138
 }
1976
-.shithub-search-list li:last-child { border-bottom: none; }
2139
+.shithub-search-result-main {
1977
-.shithub-search-list small { color: var(--fg-muted); }
2140
+  min-width: 0;
2141
+}
2142
+.shithub-search-result h2 {
2143
+  display: flex;
2144
+  align-items: center;
2145
+  gap: 0.45rem;
2146
+  margin: 0;
2147
+  font-size: 1rem;
2148
+  line-height: 1.35;
2149
+  font-weight: 600;
2150
+}
2151
+.shithub-search-result h2 svg {
2152
+  flex: 0 0 auto;
2153
+  color: var(--fg-muted);
2154
+}
2155
+.shithub-search-result p {
2156
+  margin: 0.35rem 0 0;
2157
+  color: var(--fg-default);
2158
+  font-size: 0.875rem;
2159
+}
2160
+.shithub-search-result-path {
2161
+  color: var(--fg-muted) !important;
2162
+}
2163
+.shithub-search-avatar,
2164
+.shithub-search-user-avatar {
2165
+  border-radius: 50%;
2166
+  background: var(--canvas-subtle);
2167
+  flex: 0 0 auto;
2168
+}
2169
+.shithub-search-result-meta {
2170
+  display: flex;
2171
+  gap: 0.5rem;
2172
+  flex-wrap: wrap;
2173
+  align-items: center;
2174
+  padding: 0;
2175
+  margin: 0.65rem 0 0;
2176
+  color: var(--fg-muted);
2177
+  list-style: none;
2178
+  font-size: 0.75rem;
2179
+}
2180
+.shithub-search-result-meta li {
2181
+  display: inline-flex;
2182
+  align-items: center;
2183
+  gap: 0.25rem;
2184
+}
2185
+.shithub-search-result-meta li + li::before {
2186
+  content: "";
2187
+  width: 3px;
2188
+  height: 3px;
2189
+  margin-right: 0.15rem;
2190
+  border-radius: 50%;
2191
+  background: var(--fg-muted);
2192
+  opacity: 0.7;
2193
+}
2194
+.shithub-search-star {
2195
+  align-self: flex-start;
2196
+  color: var(--fg-default);
2197
+  border-color: var(--border-default);
2198
+  background: var(--canvas-subtle);
2199
+}
2200
+.shithub-search-state {
2201
+  display: inline-flex;
2202
+  align-items: center;
2203
+  line-height: 0;
2204
+}
2205
+.shithub-search-state-open svg { color: var(--success-fg); }
2206
+.shithub-search-state-closed svg { color: #8250df; }
2207
+.shithub-search-state-pr svg { color: var(--accent-fg); }
2208
+.shithub-search-user-result {
2209
+  justify-content: flex-start;
2210
+}
2211
+.shithub-search-code-preview {
2212
+  margin: 0.75rem 0 0;
2213
+  padding: 0.65rem 0.75rem;
2214
+  border: 1px solid var(--border-default);
2215
+  border-radius: 6px;
2216
+  background: var(--canvas-subtle);
2217
+  color: var(--fg-default);
2218
+  overflow-x: auto;
2219
+}
2220
+.shithub-search-code-preview code {
2221
+  padding: 0;
2222
+  background: transparent;
2223
+}
2224
+.shithub-search-empty,
2225
+.shithub-search-blank {
2226
+  padding: 2rem 0;
2227
+  color: var(--fg-muted);
2228
+}
2229
+.shithub-search-blank h1 {
2230
+  margin: 0 0 0.4rem;
2231
+  color: var(--fg-default);
2232
+  font-size: 1.25rem;
2233
+}
2234
+.shithub-search-blank p {
2235
+  margin: 0;
2236
+}
2237
+.shithub-search-pagination {
2238
+  display: flex;
2239
+  justify-content: center;
2240
+  gap: 0.5rem;
2241
+  padding: 1.5rem 0 0;
2242
+}
1978
 .shithub-quick-dropdown { padding: 0.5rem; }
2243
 .shithub-quick-dropdown { padding: 0.5rem; }
1979
 .shithub-quick-section { padding: 0.25rem 0; border-bottom: 1px solid var(--border-default); }
2244
 .shithub-quick-section { padding: 0.25rem 0; border-bottom: 1px solid var(--border-default); }
1980
 .shithub-quick-section:last-of-type { border-bottom: none; }
2245
 .shithub-quick-section:last-of-type { border-bottom: none; }
@@ -1982,6 +2247,45 @@ code {
1982
 .shithub-quick-section ul { list-style: none; padding: 0; margin: 0; }
2247
 .shithub-quick-section ul { list-style: none; padding: 0; margin: 0; }
1983
 .shithub-quick-section li { padding: 0.25rem 0; }
2248
 .shithub-quick-section li { padding: 0.25rem 0; }
1984
 .shithub-quick-footer { padding: 0.5rem 0; border-top: 1px solid var(--border-default); text-align: center; }
2249
 .shithub-quick-footer { padding: 0.5rem 0; border-top: 1px solid var(--border-default); text-align: center; }
2250
+@media (max-width: 760px) {
2251
+  .shithub-nav-search {
2252
+    order: 3;
2253
+    flex-basis: 100%;
2254
+    max-width: none;
2255
+    margin: 0;
2256
+  }
2257
+  .shithub-search-page {
2258
+    padding: 1rem;
2259
+  }
2260
+  .shithub-search-shell {
2261
+    grid-template-columns: 1fr;
2262
+    gap: 1rem;
2263
+  }
2264
+  .shithub-search-sidebar {
2265
+    padding-top: 0;
2266
+  }
2267
+  .shithub-search-filter-list {
2268
+    flex-direction: row;
2269
+    gap: 0.25rem;
2270
+    overflow-x: auto;
2271
+    padding-bottom: 0.25rem;
2272
+  }
2273
+  .shithub-search-filter {
2274
+    flex: 0 0 auto;
2275
+  }
2276
+  .shithub-search-filter.is-selected::before {
2277
+    display: none;
2278
+  }
2279
+  .shithub-search-query-form,
2280
+  .shithub-search-results-head,
2281
+  .shithub-search-result {
2282
+    align-items: stretch;
2283
+    flex-direction: column;
2284
+  }
2285
+  .shithub-search-star {
2286
+    align-self: flex-start;
2287
+  }
2288
+}
1985
 
2289
 
1986
 /* S34 — admin impersonation banner. Sticky-top, loud red so the
2290
 /* S34 — admin impersonation banner. Sticky-top, loud red so the
1987
    admin can't lose track of "I am viewing as someone else right
2291
    admin can't lose track of "I am viewing as someone else right
internal/web/templates/_nav.htmlmodified
@@ -5,7 +5,9 @@
5
     <span>shithub</span>
5
     <span>shithub</span>
6
   </a>
6
   </a>
7
   <form action="/search" method="get" class="shithub-nav-search" role="search">
7
   <form action="/search" method="get" class="shithub-nav-search" role="search">
8
-    <input type="text" name="q" placeholder="Search…" aria-label="Search" autocomplete="off">
8
+    <span class="shithub-nav-search-icon">{{ octicon "search" }}</span>
9
+    <input type="text" name="q"{{ with .GlobalSearchQuery }} value="{{ . }}"{{ end }} placeholder="Type / to search" aria-label="Search" autocomplete="off">
10
+    <span class="shithub-nav-search-key" aria-hidden="true">/</span>
9
     <input type="hidden" name="type" value="repos">
11
     <input type="hidden" name="type" value="repos">
10
   </form>
12
   </form>
11
   <nav class="shithub-nav-links" aria-label="Primary">
13
   <nav class="shithub-nav-links" aria-label="Primary">
internal/web/templates/search/results.htmlmodified
@@ -1,92 +1,163 @@
1
 {{ define "page" -}}
1
 {{ define "page" -}}
2
-<section class="shithub-search">
2
+<section class="shithub-search-page">
3
-  <header class="shithub-search-head">
3
+  <div class="shithub-search-shell">
4
-    <h1>Search</h1>
4
+    <aside class="shithub-search-sidebar" aria-label="Search filters">
5
-    <form action="/search" method="get" class="shithub-search-form">
5
+      <h2>Filter by</h2>
6
-      <input type="text" name="q" value="{{ .Query }}" placeholder="search repositories, issues, users, code…" autofocus>
6
+      <nav class="shithub-search-filter-list">
7
-      <input type="hidden" name="type" value="{{ .Tab }}">
7
+        {{ range .SearchTabs }}
8
-      <button type="submit" class="shithub-button shithub-button-primary">Search</button>
8
+          <a href="{{ .Href }}" class="shithub-search-filter{{ if .Selected }} is-selected{{ end }}"{{ if .Selected }} aria-current="page"{{ end }}>
9
-    </form>
9
+            <span class="shithub-search-filter-label">{{ octicon .Icon }} {{ .Label }}</span>
10
-    <nav class="shithub-search-tabs">
10
+            {{ if .Count }}<span class="shithub-search-filter-count">{{ .Count }}</span>{{ end }}
11
-      <a href="?q={{ .Query }}&type=repos" class="shithub-button {{ if eq .Tab "repos" }}shithub-button-primary{{ end }}">Repositories</a>
11
+          </a>
12
-      <a href="?q={{ .Query }}&type=issues" class="shithub-button {{ if eq .Tab "issues" }}shithub-button-primary{{ end }}">Issues</a>
13
-      <a href="?q={{ .Query }}&type=users" class="shithub-button {{ if eq .Tab "users" }}shithub-button-primary{{ end }}">Users</a>
14
-      <a href="?q={{ .Query }}&type=code" class="shithub-button {{ if eq .Tab "code" }}shithub-button-primary{{ end }}">Code</a>
15
-    </nav>
16
-  </header>
17
-
18
-  {{ if .EmptyQuery }}
19
-  <p class="shithub-empty">Type a query to search.</p>
20
-  {{ else }}
21
-    {{ if eq .Tab "repos" }}
22
-      {{ if .Repos }}
23
-        <p class="shithub-meta">{{ .Total }} repository result{{ if ne .Total 1 }}s{{ end }}</p>
24
-        <ul class="shithub-search-list">
25
-        {{ range .Repos }}
26
-          <li>
27
-            <a href="/{{ .OwnerUsername }}/{{ .Name }}"><strong>{{ .OwnerUsername }}/{{ .Name }}</strong></a>
28
-            {{ if eq .Visibility "private" }}<span class="shithub-pill shithub-pill-private">private</span>{{ end }}
29
-            <small>★ {{ .StarCount }}</small>
30
-            <small>updated {{ relativeTime .UpdatedAt }}</small>
31
-            {{ if .Description }}<p class="shithub-meta">{{ .Description }}</p>{{ end }}
32
-          </li>
33
         {{ end }}
12
         {{ end }}
34
-        </ul>
13
+      </nav>
35
-      {{ else }}<p class="shithub-empty">No repositories matched.</p>{{ end }}
14
+    </aside>
36
 
15
 
37
-    {{ else if eq .Tab "issues" }}
16
+    <div class="shithub-search-results">
38
-      {{ if .Issues }}
17
+      <form action="/search" method="get" class="shithub-search-query-form">
39
-        <p class="shithub-meta">{{ .Total }} issue result{{ if ne .Total 1 }}s{{ end }}</p>
18
+        <input type="text" name="q" value="{{ .Query }}" placeholder="Search shithub" autofocus>
40
-        <ul class="shithub-search-list">
19
+        <input type="hidden" name="type" value="{{ .Tab }}">
41
-        {{ range .Issues }}
20
+        <button type="submit" class="shithub-button shithub-button-primary">Search</button>
42
-          <li>
21
+      </form>
43
-            <a href="/{{ .OwnerUsername }}/{{ .RepoName }}/issues/{{ .Number }}">
44
-              <strong>{{ .OwnerUsername }}/{{ .RepoName }}#{{ .Number }}</strong> — {{ .Title }}
45
-            </a>
46
-            <span class="shithub-pill">{{ .State }}</span>
47
-            {{ if .AuthorName }}<small>by @{{ .AuthorName }}</small>{{ end }}
48
-            <small>{{ relativeTime .UpdatedAt }}</small>
49
-          </li>
50
-        {{ end }}
51
-        </ul>
52
-      {{ else }}<p class="shithub-empty">No issues matched.</p>{{ end }}
53
 
22
 
54
-    {{ else if eq .Tab "users" }}
23
+      {{ if .EmptyQuery }}
55
-      {{ if .Users }}
24
+        <div class="shithub-search-blank">
56
-        <p class="shithub-meta">{{ .Total }} user result{{ if ne .Total 1 }}s{{ end }}</p>
25
+          <h1>Search shithub</h1>
57
-        <ul class="shithub-search-list">
26
+          <p>Enter a query above to find repositories, code, issues, pull requests, and users.</p>
58
-        {{ range .Users }}
27
+        </div>
59
-          <li>
28
+      {{ else }}
60
-            <a href="/{{ .Username }}"><strong>{{ if .DisplayName }}{{ .DisplayName }}{{ else }}{{ .Username }}{{ end }}</strong></a>
29
+        <div class="shithub-search-results-head">
61
-            <small>@{{ .Username }}</small>
30
+          <h1>
62
-            {{ if .Bio }}<p class="shithub-meta">{{ .Bio }}</p>{{ end }}
31
+            {{ .Total }}
63
-          </li>
32
+            {{ if eq .Tab "repos" }}repository{{ if ne .Total 1 }} results{{ else }} result{{ end }}
64
-        {{ end }}
33
+            {{ else if eq .Tab "code" }}code result{{ if ne .Total 1 }}s{{ end }}
65
-        </ul>
34
+            {{ else if eq .Tab "issues" }}issue result{{ if ne .Total 1 }}s{{ end }}
66
-      {{ else }}<p class="shithub-empty">No users matched.</p>{{ end }}
35
+            {{ else if eq .Tab "pulls" }}pull request result{{ if ne .Total 1 }}s{{ end }}
36
+            {{ else if eq .Tab "users" }}user result{{ if ne .Total 1 }}s{{ end }}
37
+            {{ else }}result{{ if ne .Total 1 }}s{{ end }}
38
+            {{ end }}
39
+          </h1>
40
+          <details class="shithub-search-sort">
41
+            <summary>Sort: Best match</summary>
42
+            <div>
43
+              <span>Best match</span>
44
+              <span>Most recently updated</span>
45
+            </div>
46
+          </details>
47
+        </div>
48
+
49
+        {{ if eq .Tab "repos" }}
50
+          {{ if .Repos }}
51
+            <ol class="shithub-search-result-list">
52
+            {{ range .Repos }}
53
+              <li class="shithub-search-result shithub-search-repo-result">
54
+                <div class="shithub-search-result-main">
55
+                  <h2>
56
+                    <img src="/avatars/{{ .OwnerUsername }}" alt="" width="20" height="20" class="shithub-search-avatar">
57
+                    <a href="/{{ .OwnerUsername }}/{{ .Name }}">{{ .OwnerUsername }}/{{ .Name }}</a>
58
+                    {{ if eq .Visibility "private" }}<span class="shithub-pill shithub-pill-private">Private</span>{{ end }}
59
+                  </h2>
60
+                  {{ if .Description }}<p>{{ .Description }}</p>{{ end }}
61
+                  <ul class="shithub-search-result-meta">
62
+                    <li>{{ octicon "star" }} {{ .StarCount }}</li>
63
+                    <li>Updated {{ relativeTime .UpdatedAt }}</li>
64
+                  </ul>
65
+                </div>
66
+                <a href="/{{ .OwnerUsername }}/{{ .Name }}/stargazers" class="shithub-button shithub-button-small shithub-search-star">{{ octicon "star" }} Star</a>
67
+              </li>
68
+            {{ end }}
69
+            </ol>
70
+          {{ else }}<p class="shithub-search-empty">No repositories matched your search.</p>{{ end }}
67
 
71
 
68
-    {{ else if eq .Tab "code" }}
72
+        {{ else if eq .Tab "issues" }}
69
-      {{ if .Code }}
73
+          {{ if .Issues }}
70
-        <p class="shithub-meta">{{ .Total }} code result{{ if ne .Total 1 }}s{{ end }}</p>
74
+            <ol class="shithub-search-result-list">
71
-        <ul class="shithub-search-list">
75
+            {{ range .Issues }}
72
-        {{ range .Code }}
76
+              <li class="shithub-search-result">
73
-          <li>
77
+                <div class="shithub-search-result-main">
74
-            <a href="/{{ .OwnerUsername }}/{{ .RepoName }}/blob/{{ .RefName }}/{{ .Path }}">
78
+                  <h2>
75
-              <strong>{{ .OwnerUsername }}/{{ .RepoName }}</strong>
79
+                    <span class="shithub-search-state shithub-search-state-{{ .State }}">
76
-              <code>{{ .Path }}</code>
80
+                      {{ if eq .State "closed" }}{{ octicon "issue-closed" }}{{ else }}{{ octicon "issue-opened" }}{{ end }}
77
-            </a>
81
+                    </span>
78
-          </li>
82
+                    <a href="/{{ .OwnerUsername }}/{{ .RepoName }}/issues/{{ .Number }}">{{ .Title }}</a>
83
+                  </h2>
84
+                  <p class="shithub-search-result-path">{{ .OwnerUsername }}/{{ .RepoName }}#{{ .Number }}</p>
85
+                  <ul class="shithub-search-result-meta">
86
+                    <li>{{ .State }}</li>
87
+                    {{ if .AuthorName }}<li>Opened by @{{ .AuthorName }}</li>{{ end }}
88
+                    <li>Updated {{ relativeTime .UpdatedAt }}</li>
89
+                  </ul>
90
+                </div>
91
+              </li>
92
+            {{ end }}
93
+            </ol>
94
+          {{ else }}<p class="shithub-search-empty">No issues matched your search.</p>{{ end }}
95
+
96
+        {{ else if eq .Tab "pulls" }}
97
+          {{ if .Issues }}
98
+            <ol class="shithub-search-result-list">
99
+            {{ range .Issues }}
100
+              <li class="shithub-search-result">
101
+                <div class="shithub-search-result-main">
102
+                  <h2>
103
+                    <span class="shithub-search-state shithub-search-state-pr">{{ octicon "git-pull-request" }}</span>
104
+                    <a href="/{{ .OwnerUsername }}/{{ .RepoName }}/pulls/{{ .Number }}">{{ .Title }}</a>
105
+                  </h2>
106
+                  <p class="shithub-search-result-path">{{ .OwnerUsername }}/{{ .RepoName }}#{{ .Number }}</p>
107
+                  <ul class="shithub-search-result-meta">
108
+                    <li>{{ .State }}</li>
109
+                    {{ if .AuthorName }}<li>Opened by @{{ .AuthorName }}</li>{{ end }}
110
+                    <li>Updated {{ relativeTime .UpdatedAt }}</li>
111
+                  </ul>
112
+                </div>
113
+              </li>
114
+            {{ end }}
115
+            </ol>
116
+          {{ else }}<p class="shithub-search-empty">No pull requests matched your search.</p>{{ end }}
117
+
118
+        {{ else if eq .Tab "users" }}
119
+          {{ if .Users }}
120
+            <ol class="shithub-search-result-list">
121
+            {{ range .Users }}
122
+              <li class="shithub-search-result shithub-search-user-result">
123
+                <img src="/avatars/{{ .Username }}" alt="" width="40" height="40" class="shithub-search-user-avatar">
124
+                <div class="shithub-search-result-main">
125
+                  <h2><a href="/{{ .Username }}">{{ if .DisplayName }}{{ .DisplayName }}{{ else }}{{ .Username }}{{ end }}</a></h2>
126
+                  <p class="shithub-search-result-path">@{{ .Username }}</p>
127
+                  {{ if .Bio }}<p>{{ .Bio }}</p>{{ end }}
128
+                </div>
129
+              </li>
130
+            {{ end }}
131
+            </ol>
132
+          {{ else }}<p class="shithub-search-empty">No users matched your search.</p>{{ end }}
133
+
134
+        {{ else if eq .Tab "code" }}
135
+          {{ if .Code }}
136
+            <ol class="shithub-search-result-list">
137
+            {{ range .Code }}
138
+              <li class="shithub-search-result">
139
+                <div class="shithub-search-result-main">
140
+                  <h2>
141
+                    {{ octicon "file" }}
142
+                    <a href="/{{ .OwnerUsername }}/{{ .RepoName }}/blob/{{ .RefName }}/{{ .Path }}">{{ .Path }}</a>
143
+                  </h2>
144
+                  <p class="shithub-search-result-path">{{ .OwnerUsername }}/{{ .RepoName }} on {{ .RefName }}</p>
145
+                  {{ if .PreviewLine }}<pre class="shithub-search-code-preview"><code>{{ .PreviewLine }}</code></pre>{{ end }}
146
+                </div>
147
+              </li>
148
+            {{ end }}
149
+            </ol>
150
+          {{ else }}<p class="shithub-search-empty">No code matched your search.</p>{{ end }}
79
         {{ end }}
151
         {{ end }}
80
-        </ul>
81
-      {{ else }}<p class="shithub-empty">No code matched.</p>{{ end }}
82
-    {{ end }}
83
 
152
 
84
-    {{ if or .HasPrev .HasNext }}
153
+        {{ if or .HasPrev .HasNext }}
85
-    <nav class="shithub-pagination">
154
+        <nav class="shithub-search-pagination" aria-label="Pagination">
86
-      {{ if .HasPrev }}<a href="?q={{ .Query }}&type={{ .Tab }}&page={{ sub .Page 1 }}" class="shithub-button">Previous</a>{{ end }}
155
+          {{ if .HasPrev }}<a href="?q={{ .Query }}&type={{ .Tab }}&p={{ sub .Page 1 }}" class="shithub-button">Previous</a>{{ else }}<span class="shithub-button shithub-button-disabled">Previous</span>{{ end }}
87
-      {{ if .HasNext }}<a href="?q={{ .Query }}&type={{ .Tab }}&page={{ add .Page 1 }}" class="shithub-button">Next</a>{{ end }}
156
+          {{ if .HasNext }}<a href="?q={{ .Query }}&type={{ .Tab }}&p={{ add .Page 1 }}" class="shithub-button">Next</a>{{ else }}<span class="shithub-button shithub-button-disabled">Next</span>{{ end }}
88
-    </nav>
157
+        </nav>
89
-    {{ end }}
158
+        {{ end }}
90
-  {{ end }}
159
+      {{ end }}
160
+    </div>
161
+  </div>
91
 </section>
162
 </section>
92
 {{- end }}
163
 {{- end }}