diff --git a/.gitignore b/.gitignore index 9299bc20..176f8b35 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ tmp appview/pages/static/* result !.gitkeep +!appview/pages/static/topbar-search.js out/ node_modules/ patches diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 17084261..22500649 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -1674,6 +1674,25 @@ func (p *Pages) SearchRepos(w io.Writer, params SearchReposParams) error { return p.execute("search/search", w, params) } +type SearchQuickParams struct { + Repos []models.Repo + Query string + Total int +} + +func (p *Pages) SearchQuick(w io.Writer, params SearchQuickParams) error { + return p.executePlain("search/fragments/quick", w, params) +} + +func (p *Pages) SearchQuickMobile(w io.Writer, params SearchQuickParams) error { + tpl, err := p.parse("search/fragments/quick") + if err != nil { + return err + } + return tpl.ExecuteTemplate(w, "search/fragments/quickMobile", params) +} + + func (p *Pages) Home(w io.Writer, params TimelineParams) error { return p.execute("timeline/home", w, params) } diff --git a/appview/pages/static/topbar-search.js b/appview/pages/static/topbar-search.js new file mode 100644 index 00000000..cb1d40ca --- /dev/null +++ b/appview/pages/static/topbar-search.js @@ -0,0 +1,245 @@ +(() => { + if (window._navSearchReady) return; + window._navSearchReady = true; + + const $ = (id) => document.getElementById(id); + + const submitFromInput = (input) => { + const query = input.value.trim(); + if (query) + window.location.href = `/search?q=${encodeURIComponent(query)}`; + }; + + // mobile-related code + let savedScrollY = 0; + let touchMoveHandler = null; + + const updateOverlayHeight = () => { + const overlay = $("mobile-search-overlay"); + if (!overlay) return; + + const layoutHeight = window.innerHeight; + const visibleHeight = window.visualViewport?.height ?? layoutHeight; + + overlay.style.height = `${layoutHeight}px`; + overlay.style.top = "0px"; + + const spacer = $("mobile-search-spacer"); + if (spacer) + spacer.style.height = `${Math.max(0, layoutHeight - visibleHeight)}px`; + }; + + const openMobile = () => { + const overlay = $("mobile-search-overlay"); + if (!overlay || overlay.classList.contains("opacity-100")) return; + + overlay.classList.remove("opacity-0", "pointer-events-none"); + overlay.classList.add("opacity-100", "pointer-events-auto"); + overlay.setAttribute("aria-hidden", "false"); + + savedScrollY = window.scrollY; + Object.assign(document.body.style, { + position: "fixed", + top: `-${savedScrollY}px`, + width: "100%", + }); + updateOverlayHeight(); + + if (window.visualViewport) { + window.visualViewport.addEventListener( + "resize", + updateOverlayHeight, + ); + } + + $("mobile-search-input")?.focus({ preventScroll: true }); + + const results = $("mobile-search-results"); + if (results && !touchMoveHandler) { + touchMoveHandler = (e) => e.preventDefault(); + results.addEventListener("touchmove", touchMoveHandler, { + passive: false, + }); + } + }; + + const closeMobile = () => { + const overlay = $("mobile-search-overlay"); + if (!overlay) return; + + overlay.classList.remove("opacity-100", "pointer-events-auto"); + overlay.classList.add("opacity-0", "pointer-events-none"); + overlay.setAttribute("aria-hidden", "true"); + overlay.style.height = ""; + overlay.style.top = ""; + + const spacer = $("mobile-search-spacer"); + if (spacer) { + spacer.style.height = ""; + spacer.classList.add("hidden"); + } + + if (window.visualViewport) { + window.visualViewport.removeEventListener( + "resize", + updateOverlayHeight, + ); + } + + Object.assign(document.body.style, { + position: "", + top: "", + width: "", + }); + window.scrollTo(0, savedScrollY); + + const input = $("mobile-search-input"); + if (input) { + input.value = ""; + input.blur(); + } + + $("mobile-search-results")?.replaceChildren(); + }; + + // desktop-related things + const clearDesktop = () => { + $("topbar-search-results")?.replaceChildren(); + + const box = $("topbar-search-box"); + box?.classList.add("rounded"); + box?.classList.remove("rounded-t"); + }; + + // events + document.addEventListener("click", ({ target }) => { + // mobile: open/close overlay via data-action buttons + const action = target + .closest("[data-action]") + ?.getAttribute("data-action"); + if (action === "open-mobile-search") { + openMobile(); + return; + } + if (action === "close-mobile-search") { + closeMobile(); + return; + } + + // desktop: clicking outside the search container clears results + const container = $("topbar-search-container"); + if (container && !container.contains(target)) clearDesktop(); + }); + + // desktop: defer so a click on a result fires before results are cleared + document.addEventListener("focusout", ({ target, relatedTarget }) => { + const container = $("topbar-search-container"); + if (container?.contains(target) && !container.contains(relatedTarget)) { + setTimeout(clearDesktop, 0); + } + }); + + document.addEventListener("htmx:afterSwap", ({ detail: { target } }) => { + if (!target) return; + + // desktop: toggle rounded corners based on whether results are open + if (target.id === "topbar-search-results") { + const box = $("topbar-search-box"); + const open = target.children.length > 0; + box?.classList.toggle("rounded", !open); + box?.classList.toggle("rounded-t", open); + return; + } + + // mobile: restore touch listener and show spacer when results arrive + if (target.id === "mobile-search-results") { + if (touchMoveHandler) { + target.removeEventListener("touchmove", touchMoveHandler); + touchMoveHandler = null; + } + + const hasResults = !!target.querySelector("[data-results-footer]"); + $("mobile-search-spacer")?.classList.toggle("hidden", !hasResults); + } + }); + + document.addEventListener("keydown", (e) => { + const { key, metaKey, ctrlKey } = e; + const input = $("topbar-search-input"); + const results = $("topbar-search-results"); + const mobileOverlay = $("mobile-search-overlay"); + const mobileInput = $("mobile-search-input"); + const active = document.activeElement; + + // desktop: ⌘K / Ctrl+K focuses the search input + if ((metaKey || ctrlKey) && key === "k") { + e.preventDefault(); + input?.focus(); + input?.select(); + return; + } + + if (key === "Enter") { + if (active === input) { + e.preventDefault(); + submitFromInput(input); + return; + } // desktop + if (active === mobileInput) { + e.preventDefault(); + submitFromInput(mobileInput); + return; + } // mobile + } + + if (key === "Escape") { + // mobile: close the overlay + if ( + mobileOverlay && + !mobileOverlay.classList.contains("opacity-0") + ) { + e.preventDefault(); + closeMobile(); + return; + } + // desktop: clear results and blur + if (input) { + const links = results + ? [...results.querySelectorAll("[data-nav-result]")] + : []; + if (active === input || links.includes(active)) { + e.preventDefault(); + clearDesktop(); + input.blur(); + } + } + } + + // desktop: arrow key navigation through results + if (!input || !results) return; + + const links = [...results.querySelectorAll("[data-nav-result]")]; + const inputFocused = active === input; + const focusedIndex = links.indexOf(active); + + if (key === "ArrowDown") { + if (inputFocused && links.length) { + e.preventDefault(); + links[0].focus(); + } else if (focusedIndex >= 0 && focusedIndex < links.length - 1) { + e.preventDefault(); + links[focusedIndex + 1].focus(); + } + } + + if (key === "ArrowUp") { + if (focusedIndex === 0) { + e.preventDefault(); + input.focus(); + } else if (focusedIndex > 0) { + e.preventDefault(); + links[focusedIndex - 1].focus(); + } + } + }); +})(); diff --git a/appview/pages/templates/layouts/base.html b/appview/pages/templates/layouts/base.html index aa9a508a..7c26a0c6 100644 --- a/appview/pages/templates/layouts/base.html +++ b/appview/pages/templates/layouts/base.html @@ -3,7 +3,7 @@ - + @@ -23,6 +23,7 @@ + @@ -68,7 +69,7 @@ {{ block "topbarLayout" . }} -
+
{{ if .LoggedInUser }}
+
{{ template "layouts/fragments/footer" . }}
{{ end }} diff --git a/appview/pages/templates/layouts/fragments/topbar.html b/appview/pages/templates/layouts/fragments/topbar.html index 02f17ca9..ee03ffad 100644 --- a/appview/pages/templates/layouts/fragments/topbar.html +++ b/appview/pages/templates/layouts/fragments/topbar.html @@ -9,10 +9,11 @@
{{ with .LoggedInUser }} - {{ block "newButton" . }} {{ end }} - {{ template "notifications/fragments/bell" }} - {{ block "searchButton" . }} {{ end }} - {{ block "profileDropdown" . }} {{ end }} +
{{ block "newButton" . }} {{ end }}
+
{{ template "notifications/fragments/bell" }}
+
{{ block "search" . }} {{ end }}
+ +
{{ block "profileDropdown" . }} {{ end }}
{{ else }} login or @@ -43,8 +44,91 @@ {{ end }} -{{ define "searchButton" }} -{{ i "search" "size-5 text-gray-500 dark:text-gray-400" }} +{{ define "search" }} + + + + + + {{ end }} {{ define "profileDropdown" }} diff --git a/appview/pages/templates/search/fragments/quick.html b/appview/pages/templates/search/fragments/quick.html new file mode 100644 index 00000000..1250cb18 --- /dev/null +++ b/appview/pages/templates/search/fragments/quick.html @@ -0,0 +1,45 @@ +{{define "search/fragments/quick"}} +{{- if .Repos -}} +
+
+ {{ template "search/fragments/quickPage" . }} +
+ {{ template "search/fragments/quickFooter" . }} +
+{{- else if .Query -}} +
+ no results for "{{ .Query }}" +
+{{- end -}} +{{end}} + +{{define "search/fragments/quickMobile"}} +{{- if .Repos -}} +
+
+ {{ template "search/fragments/quickPage" . }} +
+ {{ template "search/fragments/quickFooter" . }} +
+{{- else if .Query -}} +
+ no results for "{{ .Query }}" +
+{{- end -}} +{{end}} + +{{define "search/fragments/quickPage"}} +{{- range .Repos -}} + {{ template "user/fragments/repoCard" (list $ . true false (dict) true) }} +{{- end -}} +{{end}} + +{{define "search/fragments/quickFooter"}} +
+ showing {{ len .Repos }} of {{ .Total }} + + all results {{ i "arrow-right" "size-4" }} + +
+{{end}} diff --git a/appview/pages/templates/user/fragments/repoCard.html b/appview/pages/templates/user/fragments/repoCard.html index d8319ebe..7d487b71 100644 --- a/appview/pages/templates/user/fragments/repoCard.html +++ b/appview/pages/templates/user/fragments/repoCard.html @@ -5,15 +5,19 @@ {{ $fullName := index . 2 }} {{ $starButton := false }} {{ $starData := dict }} + {{ $compact := false }} {{ if gt (len .) 3 }} {{ $starButton = index . 3 }} {{ if gt (len .) 4 }} {{ $starData = index . 4 }} + {{ if gt (len .) 5 }} + {{ $compact = index . 5 }} + {{ end }} {{ end }} {{ end }} {{ with $repo }} -
+
{{ if .Source }} @@ -23,9 +27,9 @@ {{ end }} {{ $repoOwner := resolve .Did }} {{- if $fullName -}} - {{ $repoOwner }}/{{ .Name }} + {{ $repoOwner }}/{{ .Name }} {{- else -}} - {{ .Name }} + {{ .Name }} {{- end -}}
{{ if and $starButton $root.LoggedInUser }} diff --git a/appview/state/router.go b/appview/state/router.go index 8f89458c..75641f3a 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -165,6 +165,8 @@ func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { r.Post("/logout", s.Logout) r.With(middleware.Paginate).Get("/search", s.Search) + r.With(middleware.AuthMiddleware(s.oauth)).Get("/search/quick", s.SearchQuick) + r.With(middleware.AuthMiddleware(s.oauth)).Get("/search/quick/mobile", s.SearchQuickMobile) r.Post("/account/switch", s.SwitchAccount) r.With(middleware.AuthMiddleware(s.oauth)).Delete("/account/{did}", s.RemoveAccount) diff --git a/appview/state/search.go b/appview/state/search.go index 9c65c841..19afab71 100644 --- a/appview/state/search.go +++ b/appview/state/search.go @@ -1,7 +1,9 @@ package state import ( + "cmp" "net/http" + "slices" "strings" "time" @@ -68,17 +70,13 @@ func (s *State) Search(w http.ResponseWriter, r *http.Request) { return } - // sort repos to match search result order (by relevance) - repoMap := make(map[int64]models.Repo, len(repos)) - for _, repo := range repos { - repoMap[repo.Id] = repo - } - repos = make([]models.Repo, 0, len(res.Hits)) - for _, id := range res.Hits { - if repo, ok := repoMap[id]; ok { - repos = append(repos, repo) - } + hitIdx := make(map[int64]int, len(res.Hits)) + for i, id := range res.Hits { + hitIdx[id] = i } + slices.SortFunc(repos, func(a, b models.Repo) int { + return cmp.Compare(hitIdx[a.Id], hitIdx[b.Id]) + }) } resultCount = int(res.Total) @@ -158,6 +156,77 @@ func (s *State) Search(w http.ResponseWriter, r *http.Request) { } } +func (s *State) SearchQuick(w http.ResponseWriter, r *http.Request) { + s.searchQuick(w, r, false) +} + +func (s *State) SearchQuickMobile(w http.ResponseWriter, r *http.Request) { + s.searchQuick(w, r, true) +} + +func (s *State) searchQuick(w http.ResponseWriter, r *http.Request, mobile bool) { + rawQuery := r.URL.Query().Get("q") + if rawQuery == "" { + w.WriteHeader(http.StatusOK) + return + } + + const pageSize = 5 + + query := searchquery.Parse(rawQuery) + tf := searchquery.ExtractTextFilters(query) + + searchOpts := models.RepoSearchOptions{ + Keywords: tf.Keywords, + Phrases: tf.Phrases, + NegatedKeywords: tf.NegatedKeywords, + NegatedPhrases: tf.NegatedPhrases, + Page: pagination.Page{Limit: pageSize}, + } + + var repos []models.Repo + var total int + + if searchOpts.HasSearchFilters() { + res, err := s.indexer.Repos.Search(r.Context(), searchOpts) + if err != nil { + s.logger.Error("failed quick search", "err", err) + http.Error(w, "search failed", http.StatusInternalServerError) + return + } + total = int(res.Total) + if len(res.Hits) > 0 { + repos, err = db.GetRepos(s.db, orm.FilterIn("id", res.Hits)) + if err != nil { + s.logger.Error("failed to get repos for quick search", "err", err) + http.Error(w, "search failed", http.StatusInternalServerError) + return + } + hitIdx := make(map[int64]int, len(res.Hits)) + for i, id := range res.Hits { + hitIdx[id] = i + } + slices.SortFunc(repos, func(a, b models.Repo) int { + return cmp.Compare(hitIdx[a.Id], hitIdx[b.Id]) + }) + } + } + + params := pages.SearchQuickParams{ + Repos: repos, + Query: rawQuery, + Total: total, + } + + render := s.pages.SearchQuick + if mobile { + render = s.pages.SearchQuickMobile + } + if err := render(w, params); err != nil { + s.logger.Error("failed to render quick search", "err", err) + } +} + // parseSortParam parses sort parameter like "stars-desc" or "created-asc" func parseSortParam(sortParam string) (string, bool) { defaultSort := func() (string, bool) { return "relevance", true }