tenseleyflow/shithub / 6dd134b

Browse files

S28: /search + /search/quick handlers, templates, top-bar nav

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
6dd134bc22ffb140eb48550a7473f5e55ff3467b
Parents
204a28d
Tree
2701a9a

8 changed files

StatusFile+-
M internal/web/handlers/handlers.go 7 0
A internal/web/handlers/search/search.go 185 0
A internal/web/search_wiring.go 29 0
M internal/web/server.go 7 0
M internal/web/static/css/shithub.css 38 0
M internal/web/templates/_nav.html 4 0
A internal/web/templates/search/_quick_dropdown.html 40 0
A internal/web/templates/search/results.html 92 0
internal/web/handlers/handlers.gomodified
@@ -86,6 +86,10 @@ type Deps struct {
8686
 	// (S27). The forks list GET is public; fork + sync POSTs are
8787
 	// auth-required.
8888
 	RepoForkMounter func(chi.Router)
89
+	// SearchMounter registers /search and /search/quick (S28).
90
+	// Both are public — visibility scoping is done inside the
91
+	// search package via policy.VisibilityPredicate.
92
+	SearchMounter func(chi.Router)
8993
 	// GitHTTPMounter, when non-nil, registers the smart-HTTP git routes
9094
 	// (`*.git/info/refs`, `git-upload-pack`, `git-receive-pack`). MUST
9195
 	// land in a route group that bypasses CSRF, response compression,
@@ -216,6 +220,9 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
216220
 		if deps.RepoForkMounter != nil {
217221
 			deps.RepoForkMounter(r)
218222
 		}
223
+		if deps.SearchMounter != nil {
224
+			deps.SearchMounter(r)
225
+		}
219226
 		if deps.RepoHomeMounter != nil {
220227
 			deps.RepoHomeMounter(r)
221228
 		}
internal/web/handlers/search/search.goadded
@@ -0,0 +1,185 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package search wires the S28 web search surface. The full results
4
+// page lives at GET /search; the htmx quick dropdown lives at GET
5
+// /search/quick.
6
+package search
7
+
8
+import (
9
+	"errors"
10
+	"log/slog"
11
+	"net/http"
12
+
13
+	"github.com/go-chi/chi/v5"
14
+	"github.com/jackc/pgx/v5/pgxpool"
15
+
16
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
17
+	srch "github.com/tenseleyFlow/shithub/internal/search"
18
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
19
+	"github.com/tenseleyFlow/shithub/internal/web/render"
20
+)
21
+
22
+// Deps wires the handler set.
23
+type Deps struct {
24
+	Logger *slog.Logger
25
+	Render *render.Renderer
26
+	Pool   *pgxpool.Pool
27
+}
28
+
29
+// Handlers is the registered handler set. Construct via New.
30
+type Handlers struct {
31
+	d Deps
32
+}
33
+
34
+// New constructs the handler set, validating Deps.
35
+func New(d Deps) (*Handlers, error) {
36
+	if d.Render == nil {
37
+		return nil, errors.New("search: nil Render")
38
+	}
39
+	if d.Pool == nil {
40
+		return nil, errors.New("search: nil Pool")
41
+	}
42
+	return &Handlers{d: d}, nil
43
+}
44
+
45
+// Mount registers /search and /search/quick.
46
+func (h *Handlers) Mount(r chi.Router) {
47
+	r.Get("/search", h.results)
48
+	r.Get("/search/quick", h.quick)
49
+}
50
+
51
+func (h *Handlers) deps() srch.Deps {
52
+	return srch.Deps{Pool: h.d.Pool, Logger: h.d.Logger}
53
+}
54
+
55
+func (h *Handlers) actor(r *http.Request) policy.Actor {
56
+	viewer := middleware.CurrentUserFromContext(r.Context())
57
+	if viewer.IsAnonymous() {
58
+		return policy.AnonymousActor()
59
+	}
60
+	return policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false)
61
+}
62
+
63
+// results renders the full /search page with type tabs.
64
+func (h *Handlers) results(w http.ResponseWriter, r *http.Request) {
65
+	rawQ := r.URL.Query().Get("q")
66
+	tab := r.URL.Query().Get("type")
67
+	if tab == "" {
68
+		tab = "repos"
69
+	}
70
+	page := pageFromRequest(r)
71
+
72
+	parsed := srch.ParseQuery(rawQ)
73
+	actor := h.actor(r)
74
+	deps := h.deps()
75
+
76
+	data := map[string]any{
77
+		"Title":    "Search",
78
+		"Query":    rawQ,
79
+		"Tab":      tab,
80
+		"Page":     page,
81
+		"Parsed":   parsed,
82
+		"PageSize": srch.PageSize,
83
+	}
84
+
85
+	if !parsed.HasContent() {
86
+		data["EmptyQuery"] = true
87
+		_ = h.d.Render.RenderPage(w, r, "search/results", data)
88
+		return
89
+	}
90
+
91
+	offset := (page - 1) * srch.PageSize
92
+	switch tab {
93
+	case "repos":
94
+		rows, total, err := srch.SearchRepos(r.Context(), deps, actor, parsed, srch.PageSize, offset)
95
+		if err != nil && !errors.Is(err, srch.ErrEmptyQuery) {
96
+			h.d.Logger.ErrorContext(r.Context(), "search repos", "error", err)
97
+		}
98
+		data["Repos"] = rows
99
+		data["Total"] = total
100
+		data["HasNext"] = int64(page*srch.PageSize) < total
101
+	case "issues":
102
+		rows, total, err := srch.SearchIssues(r.Context(), deps, actor, parsed, "issue", srch.PageSize, offset)
103
+		if err != nil && !errors.Is(err, srch.ErrEmptyQuery) {
104
+			h.d.Logger.ErrorContext(r.Context(), "search issues", "error", err)
105
+		}
106
+		data["Issues"] = rows
107
+		data["Total"] = total
108
+		data["HasNext"] = int64(page*srch.PageSize) < total
109
+	case "users":
110
+		rows, total, err := srch.SearchUsers(r.Context(), deps, parsed, srch.PageSize, offset)
111
+		if err != nil && !errors.Is(err, srch.ErrEmptyQuery) {
112
+			h.d.Logger.ErrorContext(r.Context(), "search users", "error", err)
113
+		}
114
+		data["Users"] = rows
115
+		data["Total"] = total
116
+		data["HasNext"] = int64(page*srch.PageSize) < total
117
+	case "code":
118
+		rows, total, err := srch.SearchCode(r.Context(), deps, actor, parsed, srch.PageSize, offset)
119
+		if err != nil && !errors.Is(err, srch.ErrEmptyQuery) {
120
+			h.d.Logger.ErrorContext(r.Context(), "search code", "error", err)
121
+		}
122
+		data["Code"] = rows
123
+		data["Total"] = total
124
+		data["HasNext"] = int64(page*srch.PageSize) < total
125
+	default:
126
+		// Unknown tab → render the page with the empty-state shape
127
+		// rather than 400 (a typo in the URL shouldn't be a hard
128
+		// error).
129
+		data["EmptyQuery"] = true
130
+	}
131
+	data["HasPrev"] = page > 1
132
+
133
+	if err := h.d.Render.RenderPage(w, r, "search/results", data); err != nil {
134
+		h.d.Logger.ErrorContext(r.Context(), "search render", "error", err)
135
+	}
136
+}
137
+
138
+// quick is the htmx dropdown endpoint. Returns one fragment with
139
+// the top N results across all four types stacked vertically.
140
+func (h *Handlers) quick(w http.ResponseWriter, r *http.Request) {
141
+	rawQ := r.URL.Query().Get("q")
142
+	parsed := srch.ParseQuery(rawQ)
143
+	if !parsed.HasContent() {
144
+		w.WriteHeader(http.StatusNoContent)
145
+		return
146
+	}
147
+	actor := h.actor(r)
148
+	deps := h.deps()
149
+
150
+	repos, _, _ := srch.SearchRepos(r.Context(), deps, actor, parsed, srch.QuickResultsLimit, 0)
151
+	issues, _, _ := srch.SearchIssues(r.Context(), deps, actor, parsed, "", srch.QuickResultsLimit, 0)
152
+	users, _, _ := srch.SearchUsers(r.Context(), deps, parsed, srch.QuickResultsLimit, 0)
153
+
154
+	data := map[string]any{
155
+		"Query":  rawQ,
156
+		"Repos":  repos,
157
+		"Issues": issues,
158
+		"Users":  users,
159
+	}
160
+	if err := h.d.Render.RenderPage(w, r, "search/_quick_dropdown", data); err != nil {
161
+		h.d.Logger.ErrorContext(r.Context(), "quick render", "error", err)
162
+	}
163
+}
164
+
165
+// pageFromRequest pulls ?page=N, defaulting to 1 on missing/invalid.
166
+func pageFromRequest(r *http.Request) int {
167
+	p := r.URL.Query().Get("page")
168
+	if p == "" {
169
+		return 1
170
+	}
171
+	n := 0
172
+	for _, c := range p {
173
+		if c < '0' || c > '9' {
174
+			return 1
175
+		}
176
+		n = n*10 + int(c-'0')
177
+		if n > 10000 {
178
+			return 1
179
+		}
180
+	}
181
+	if n < 1 {
182
+		return 1
183
+	}
184
+	return n
185
+}
internal/web/search_wiring.goadded
@@ -0,0 +1,29 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package web
4
+
5
+import (
6
+	"io/fs"
7
+	"log/slog"
8
+
9
+	"github.com/jackc/pgx/v5/pgxpool"
10
+
11
+	searchhandlers "github.com/tenseleyFlow/shithub/internal/web/handlers/search"
12
+	"github.com/tenseleyFlow/shithub/internal/web/render"
13
+)
14
+
15
+// buildSearchHandlers wires the S28 search handler set. Owns its own
16
+// renderer (same pattern as the other handler builders).
17
+func buildSearchHandlers(
18
+	pool *pgxpool.Pool,
19
+	tmplFS fs.FS,
20
+	logger *slog.Logger,
21
+) (*searchhandlers.Handlers, error) {
22
+	rr, err := render.New(tmplFS, render.Options{Octicons: render.BuiltinOcticons()})
23
+	if err != nil {
24
+		return nil, err
25
+	}
26
+	return searchhandlers.New(searchhandlers.Deps{
27
+		Logger: logger, Render: rr, Pool: pool,
28
+	})
29
+}
internal/web/server.gomodified
@@ -206,6 +206,13 @@ func Run(ctx context.Context, opts Options) error {
206206
 		deps.RepoPullsMounter = repoH.MountPulls
207207
 		deps.RepoSocialMounter = repoH.MountSocial
208208
 		deps.RepoForkMounter = repoH.MountFork
209
+
210
+		searchH, err := buildSearchHandlers(pool, deps.TemplatesFS, logger)
211
+		if err != nil {
212
+			return fmt.Errorf("search handlers: %w", err)
213
+		}
214
+		deps.SearchMounter = searchH.Mount
215
+
209216
 		// Lifecycle danger-zone routes — also auth-required.
210217
 		deps.RepoLifecycleMounter = func(r chi.Router) {
211218
 			r.Group(func(r chi.Router) {
internal/web/static/css/shithub.cssmodified
@@ -1399,3 +1399,41 @@ code {
13991399
 .shithub-pagination {
14001400
   display: flex; gap: 0.5rem; padding: 1rem 0;
14011401
 }
1402
+
1403
+/* S28 — search */
1404
+.shithub-nav-search {
1405
+  flex: 1;
1406
+  display: flex;
1407
+  max-width: 32rem;
1408
+  margin: 0 1rem;
1409
+}
1410
+.shithub-nav-search input {
1411
+  width: 100%;
1412
+  padding: 0.4rem 0.7rem;
1413
+  border: 1px solid var(--border-default);
1414
+  border-radius: 6px;
1415
+  background: var(--canvas-subtle);
1416
+  color: var(--fg-default);
1417
+  font-size: 0.85rem;
1418
+}
1419
+.shithub-search { padding: 1rem 0; }
1420
+.shithub-search-head { padding-bottom: 0.5rem; border-bottom: 1px solid var(--border-default); margin-bottom: 1rem; }
1421
+.shithub-search-form { display: flex; gap: 0.5rem; margin: 0.5rem 0; }
1422
+.shithub-search-form input[type=text] { flex: 1; padding: 0.5rem 0.7rem; }
1423
+.shithub-search-tabs { display: flex; gap: 0.5rem; margin: 0.5rem 0; }
1424
+.shithub-search-list { list-style: none; padding: 0; margin: 0.5rem 0; }
1425
+.shithub-search-list li {
1426
+  display: flex; gap: 0.5rem; align-items: baseline;
1427
+  flex-wrap: wrap;
1428
+  padding: 0.5rem 0;
1429
+  border-bottom: 1px solid var(--border-default);
1430
+}
1431
+.shithub-search-list li:last-child { border-bottom: none; }
1432
+.shithub-search-list small { color: var(--fg-muted); }
1433
+.shithub-quick-dropdown { padding: 0.5rem; }
1434
+.shithub-quick-section { padding: 0.25rem 0; border-bottom: 1px solid var(--border-default); }
1435
+.shithub-quick-section:last-of-type { border-bottom: none; }
1436
+.shithub-quick-section h3 { font-size: 0.7rem; text-transform: uppercase; color: var(--fg-muted); margin: 0.25rem 0; }
1437
+.shithub-quick-section ul { list-style: none; padding: 0; margin: 0; }
1438
+.shithub-quick-section li { padding: 0.25rem 0; }
1439
+.shithub-quick-footer { padding: 0.5rem 0; border-top: 1px solid var(--border-default); text-align: center; }
internal/web/templates/_nav.htmlmodified
@@ -4,6 +4,10 @@
44
     {{ octicon "shithub" }}
55
     <span>shithub</span>
66
   </a>
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">
9
+    <input type="hidden" name="type" value="repos">
10
+  </form>
711
   <nav class="shithub-nav-links" aria-label="Primary">
812
     <a href="/explore">Explore</a>
913
     <a href="/about">About</a>
internal/web/templates/search/_quick_dropdown.htmladded
@@ -0,0 +1,40 @@
1
+{{ define "page" -}}
2
+<div class="shithub-quick-dropdown" role="listbox">
3
+  {{ if .Repos }}
4
+    <div class="shithub-quick-section">
5
+      <h3>Repositories</h3>
6
+      <ul>
7
+        {{ range .Repos }}
8
+        <li>
9
+          <a href="/{{ .OwnerUsername }}/{{ .Name }}">{{ .OwnerUsername }}/{{ .Name }}</a>
10
+          {{ if eq .Visibility "private" }}<span class="shithub-pill shithub-pill-private">private</span>{{ end }}
11
+        </li>
12
+        {{ end }}
13
+      </ul>
14
+    </div>
15
+  {{ end }}
16
+  {{ if .Issues }}
17
+    <div class="shithub-quick-section">
18
+      <h3>Issues</h3>
19
+      <ul>
20
+        {{ range .Issues }}
21
+        <li><a href="/{{ .OwnerUsername }}/{{ .RepoName }}/issues/{{ .Number }}">{{ .OwnerUsername }}/{{ .RepoName }}#{{ .Number }} — {{ .Title }}</a></li>
22
+        {{ end }}
23
+      </ul>
24
+    </div>
25
+  {{ end }}
26
+  {{ if .Users }}
27
+    <div class="shithub-quick-section">
28
+      <h3>Users</h3>
29
+      <ul>
30
+        {{ range .Users }}
31
+        <li><a href="/{{ .Username }}">@{{ .Username }} — {{ .DisplayName }}</a></li>
32
+        {{ end }}
33
+      </ul>
34
+    </div>
35
+  {{ end }}
36
+  <div class="shithub-quick-footer">
37
+    <a href="/search?q={{ .Query }}">See all results &rarr;</a>
38
+  </div>
39
+</div>
40
+{{- end }}
internal/web/templates/search/results.htmladded
@@ -0,0 +1,92 @@
1
+{{ define "page" -}}
2
+<section class="shithub-search">
3
+  <header class="shithub-search-head">
4
+    <h1>Search</h1>
5
+    <form action="/search" method="get" class="shithub-search-form">
6
+      <input type="text" name="q" value="{{ .Query }}" placeholder="search repositories, issues, users, code…" autofocus>
7
+      <input type="hidden" name="type" value="{{ .Tab }}">
8
+      <button type="submit" class="shithub-button shithub-button-primary">Search</button>
9
+    </form>
10
+    <nav class="shithub-search-tabs">
11
+      <a href="?q={{ .Query }}&type=repos" class="shithub-button {{ if eq .Tab "repos" }}shithub-button-primary{{ end }}">Repositories</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 }}
34
+        </ul>
35
+      {{ else }}<p class="shithub-empty">No repositories matched.</p>{{ end }}
36
+
37
+    {{ else if eq .Tab "issues" }}
38
+      {{ if .Issues }}
39
+        <p class="shithub-meta">{{ .Total }} issue result{{ if ne .Total 1 }}s{{ end }}</p>
40
+        <ul class="shithub-search-list">
41
+        {{ range .Issues }}
42
+          <li>
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
+
54
+    {{ else if eq .Tab "users" }}
55
+      {{ if .Users }}
56
+        <p class="shithub-meta">{{ .Total }} user result{{ if ne .Total 1 }}s{{ end }}</p>
57
+        <ul class="shithub-search-list">
58
+        {{ range .Users }}
59
+          <li>
60
+            <a href="/{{ .Username }}"><strong>{{ if .DisplayName }}{{ .DisplayName }}{{ else }}{{ .Username }}{{ end }}</strong></a>
61
+            <small>@{{ .Username }}</small>
62
+            {{ if .Bio }}<p class="shithub-meta">{{ .Bio }}</p>{{ end }}
63
+          </li>
64
+        {{ end }}
65
+        </ul>
66
+      {{ else }}<p class="shithub-empty">No users matched.</p>{{ end }}
67
+
68
+    {{ else if eq .Tab "code" }}
69
+      {{ if .Code }}
70
+        <p class="shithub-meta">{{ .Total }} code result{{ if ne .Total 1 }}s{{ end }}</p>
71
+        <ul class="shithub-search-list">
72
+        {{ range .Code }}
73
+          <li>
74
+            <a href="/{{ .OwnerUsername }}/{{ .RepoName }}/blob/{{ .RefName }}/{{ .Path }}">
75
+              <strong>{{ .OwnerUsername }}/{{ .RepoName }}</strong>
76
+              <code>{{ .Path }}</code>
77
+            </a>
78
+          </li>
79
+        {{ end }}
80
+        </ul>
81
+      {{ else }}<p class="shithub-empty">No code matched.</p>{{ end }}
82
+    {{ end }}
83
+
84
+    {{ if or .HasPrev .HasNext }}
85
+    <nav class="shithub-pagination">
86
+      {{ if .HasPrev }}<a href="?q={{ .Query }}&type={{ .Tab }}&page={{ sub .Page 1 }}" class="shithub-button">Previous</a>{{ end }}
87
+      {{ if .HasNext }}<a href="?q={{ .Query }}&type={{ .Tab }}&page={{ add .Page 1 }}" class="shithub-button">Next</a>{{ end }}
88
+    </nav>
89
+    {{ end }}
90
+  {{ end }}
91
+</section>
92
+{{- end }}