Monorepo for Tangled tangled.org
856
fork

Configure Feed

Select the types of activity you want to include in your feed.

appview: add topbar quick-search #331

open opened by eti.tf targeting master from eti.tf/core-2: eti/search-in-nav

Adds an inline search widget to the topbar.

On desktop, a search input appears directly in the nav with a dropdown of quick results (keyboard-navigable with ↑↓/Enter/Escape, ⌘K to focus). On mobile, a search button opens a fullscreen overlay with the same results.

Signed-off-by: eti eti@eti.tf

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:xu5apv6kmu5jp7g5hwdnej42/sh.tangled.repo.pull/3ml6n7w7fmq22
+492 -22
Diff #2
+1
.gitignore
··· 6 6 appview/pages/static/* 7 7 result 8 8 !.gitkeep 9 + !appview/pages/static/topbar-search.js 9 10 out/ 10 11 node_modules/ 11 12 patches
+19
appview/pages/pages.go
··· 1674 1674 return p.execute("search/search", w, params) 1675 1675 } 1676 1676 1677 + type SearchQuickParams struct { 1678 + Repos []models.Repo 1679 + Query string 1680 + Total int 1681 + } 1682 + 1683 + func (p *Pages) SearchQuick(w io.Writer, params SearchQuickParams) error { 1684 + return p.executePlain("search/fragments/quick", w, params) 1685 + } 1686 + 1687 + func (p *Pages) SearchQuickMobile(w io.Writer, params SearchQuickParams) error { 1688 + tpl, err := p.parse("search/fragments/quick") 1689 + if err != nil { 1690 + return err 1691 + } 1692 + return tpl.ExecuteTemplate(w, "search/fragments/quickMobile", params) 1693 + } 1694 + 1695 + 1677 1696 func (p *Pages) Home(w io.Writer, params TimelineParams) error { 1678 1697 return p.execute("timeline/home", w, params) 1679 1698 }
+245
appview/pages/static/topbar-search.js
··· 1 + (() => { 2 + if (window._navSearchReady) return; 3 + window._navSearchReady = true; 4 + 5 + const $ = (id) => document.getElementById(id); 6 + 7 + const submitFromInput = (input) => { 8 + const query = input.value.trim(); 9 + if (query) 10 + window.location.href = `/search?q=${encodeURIComponent(query)}`; 11 + }; 12 + 13 + // mobile-related code 14 + let savedScrollY = 0; 15 + let touchMoveHandler = null; 16 + 17 + const updateOverlayHeight = () => { 18 + const overlay = $("mobile-search-overlay"); 19 + if (!overlay) return; 20 + 21 + const layoutHeight = window.innerHeight; 22 + const visibleHeight = window.visualViewport?.height ?? layoutHeight; 23 + 24 + overlay.style.height = `${layoutHeight}px`; 25 + overlay.style.top = "0px"; 26 + 27 + const spacer = $("mobile-search-spacer"); 28 + if (spacer) 29 + spacer.style.height = `${Math.max(0, layoutHeight - visibleHeight)}px`; 30 + }; 31 + 32 + const openMobile = () => { 33 + const overlay = $("mobile-search-overlay"); 34 + if (!overlay || overlay.classList.contains("opacity-100")) return; 35 + 36 + overlay.classList.remove("opacity-0", "pointer-events-none"); 37 + overlay.classList.add("opacity-100", "pointer-events-auto"); 38 + overlay.setAttribute("aria-hidden", "false"); 39 + 40 + savedScrollY = window.scrollY; 41 + Object.assign(document.body.style, { 42 + position: "fixed", 43 + top: `-${savedScrollY}px`, 44 + width: "100%", 45 + }); 46 + updateOverlayHeight(); 47 + 48 + if (window.visualViewport) { 49 + window.visualViewport.addEventListener( 50 + "resize", 51 + updateOverlayHeight, 52 + ); 53 + } 54 + 55 + $("mobile-search-input")?.focus({ preventScroll: true }); 56 + 57 + const results = $("mobile-search-results"); 58 + if (results && !touchMoveHandler) { 59 + touchMoveHandler = (e) => e.preventDefault(); 60 + results.addEventListener("touchmove", touchMoveHandler, { 61 + passive: false, 62 + }); 63 + } 64 + }; 65 + 66 + const closeMobile = () => { 67 + const overlay = $("mobile-search-overlay"); 68 + if (!overlay) return; 69 + 70 + overlay.classList.remove("opacity-100", "pointer-events-auto"); 71 + overlay.classList.add("opacity-0", "pointer-events-none"); 72 + overlay.setAttribute("aria-hidden", "true"); 73 + overlay.style.height = ""; 74 + overlay.style.top = ""; 75 + 76 + const spacer = $("mobile-search-spacer"); 77 + if (spacer) { 78 + spacer.style.height = ""; 79 + spacer.classList.add("hidden"); 80 + } 81 + 82 + if (window.visualViewport) { 83 + window.visualViewport.removeEventListener( 84 + "resize", 85 + updateOverlayHeight, 86 + ); 87 + } 88 + 89 + Object.assign(document.body.style, { 90 + position: "", 91 + top: "", 92 + width: "", 93 + }); 94 + window.scrollTo(0, savedScrollY); 95 + 96 + const input = $("mobile-search-input"); 97 + if (input) { 98 + input.value = ""; 99 + input.blur(); 100 + } 101 + 102 + $("mobile-search-results")?.replaceChildren(); 103 + }; 104 + 105 + // desktop-related things 106 + const clearDesktop = () => { 107 + $("topbar-search-results")?.replaceChildren(); 108 + 109 + const box = $("topbar-search-box"); 110 + box?.classList.add("rounded"); 111 + box?.classList.remove("rounded-t"); 112 + }; 113 + 114 + // events 115 + document.addEventListener("click", ({ target }) => { 116 + // mobile: open/close overlay via data-action buttons 117 + const action = target 118 + .closest("[data-action]") 119 + ?.getAttribute("data-action"); 120 + if (action === "open-mobile-search") { 121 + openMobile(); 122 + return; 123 + } 124 + if (action === "close-mobile-search") { 125 + closeMobile(); 126 + return; 127 + } 128 + 129 + // desktop: clicking outside the search container clears results 130 + const container = $("topbar-search-container"); 131 + if (container && !container.contains(target)) clearDesktop(); 132 + }); 133 + 134 + // desktop: defer so a click on a result fires before results are cleared 135 + document.addEventListener("focusout", ({ target, relatedTarget }) => { 136 + const container = $("topbar-search-container"); 137 + if (container?.contains(target) && !container.contains(relatedTarget)) { 138 + setTimeout(clearDesktop, 0); 139 + } 140 + }); 141 + 142 + document.addEventListener("htmx:afterSwap", ({ detail: { target } }) => { 143 + if (!target) return; 144 + 145 + // desktop: toggle rounded corners based on whether results are open 146 + if (target.id === "topbar-search-results") { 147 + const box = $("topbar-search-box"); 148 + const open = target.children.length > 0; 149 + box?.classList.toggle("rounded", !open); 150 + box?.classList.toggle("rounded-t", open); 151 + return; 152 + } 153 + 154 + // mobile: restore touch listener and show spacer when results arrive 155 + if (target.id === "mobile-search-results") { 156 + if (touchMoveHandler) { 157 + target.removeEventListener("touchmove", touchMoveHandler); 158 + touchMoveHandler = null; 159 + } 160 + 161 + const hasResults = !!target.querySelector("[data-results-footer]"); 162 + $("mobile-search-spacer")?.classList.toggle("hidden", !hasResults); 163 + } 164 + }); 165 + 166 + document.addEventListener("keydown", (e) => { 167 + const { key, metaKey, ctrlKey } = e; 168 + const input = $("topbar-search-input"); 169 + const results = $("topbar-search-results"); 170 + const mobileOverlay = $("mobile-search-overlay"); 171 + const mobileInput = $("mobile-search-input"); 172 + const active = document.activeElement; 173 + 174 + // desktop: ⌘K / Ctrl+K focuses the search input 175 + if ((metaKey || ctrlKey) && key === "k") { 176 + e.preventDefault(); 177 + input?.focus(); 178 + input?.select(); 179 + return; 180 + } 181 + 182 + if (key === "Enter") { 183 + if (active === input) { 184 + e.preventDefault(); 185 + submitFromInput(input); 186 + return; 187 + } // desktop 188 + if (active === mobileInput) { 189 + e.preventDefault(); 190 + submitFromInput(mobileInput); 191 + return; 192 + } // mobile 193 + } 194 + 195 + if (key === "Escape") { 196 + // mobile: close the overlay 197 + if ( 198 + mobileOverlay && 199 + !mobileOverlay.classList.contains("opacity-0") 200 + ) { 201 + e.preventDefault(); 202 + closeMobile(); 203 + return; 204 + } 205 + // desktop: clear results and blur 206 + if (input) { 207 + const links = results 208 + ? [...results.querySelectorAll("[data-nav-result]")] 209 + : []; 210 + if (active === input || links.includes(active)) { 211 + e.preventDefault(); 212 + clearDesktop(); 213 + input.blur(); 214 + } 215 + } 216 + } 217 + 218 + // desktop: arrow key navigation through results 219 + if (!input || !results) return; 220 + 221 + const links = [...results.querySelectorAll("[data-nav-result]")]; 222 + const inputFocused = active === input; 223 + const focusedIndex = links.indexOf(active); 224 + 225 + if (key === "ArrowDown") { 226 + if (inputFocused && links.length) { 227 + e.preventDefault(); 228 + links[0].focus(); 229 + } else if (focusedIndex >= 0 && focusedIndex < links.length - 1) { 230 + e.preventDefault(); 231 + links[focusedIndex + 1].focus(); 232 + } 233 + } 234 + 235 + if (key === "ArrowUp") { 236 + if (focusedIndex === 0) { 237 + e.preventDefault(); 238 + input.focus(); 239 + } else if (focusedIndex > 0) { 240 + e.preventDefault(); 241 + links[focusedIndex - 1].focus(); 242 + } 243 + } 244 + }); 245 + })();
+4 -3
appview/pages/templates/layouts/base.html
··· 3 3 <html lang="en" class="dark:bg-gray-900"> 4 4 <head> 5 5 <meta charset="UTF-8" /> 6 - <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/> 7 7 <meta name="description" content="The next-generation social coding platform."/> 8 8 <meta name="htmx-config" content='{"includeIndicatorStyles": false}'> 9 9 ··· 23 23 <script defer src="/static/htmx.min.js"></script> 24 24 <script defer src="/static/htmx-ext-ws.min.js"></script> 25 25 <script defer src="/static/actor-typeahead.js" type="module"></script> 26 + <script defer src="/static/topbar-search.js"></script> 26 27 27 28 <link rel="icon" href="/static/logos/dolly.ico" sizes="48x48"/> 28 29 <link rel="icon" href="/static/logos/dolly.svg" sizes="any" type="image/svg+xml"/> ··· 68 69 </head> 69 70 <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200 {{ block "bodyClasses" . }} {{ end }}"> 70 71 {{ block "topbarLayout" . }} 71 - <header class="w-full col-span-full md:col-span-1 md:col-start-2 drop-shadow-sm dark:text-white bg-white dark:bg-gray-800" style="z-index: 20;"> 72 + <header class="w-full col-span-full md:col-span-1 md:col-start-2 shadow-sm dark:text-white bg-white dark:bg-gray-800 pt-[env(safe-area-inset-top)]" style="z-index: 20;"> 72 73 73 74 {{ if .LoggedInUser }} 74 75 <div id="upgrade-banner" ··· 100 101 {{ end }} 101 102 102 103 {{ block "footerLayout" . }} 103 - <footer class="mt-12"> 104 + <footer class="mt-12 pb-[env(safe-area-inset-bottom)]"> 104 105 {{ template "layouts/fragments/footer" . }} 105 106 </footer> 106 107 {{ end }}
+90 -6
appview/pages/templates/layouts/fragments/topbar.html
··· 9 9 10 10 <div id="right-items" class="flex items-center gap-4"> 11 11 {{ with .LoggedInUser }} 12 - {{ block "newButton" . }} {{ end }} 13 - {{ template "notifications/fragments/bell" }} 14 - {{ block "searchButton" . }} {{ end }} 15 - {{ block "profileDropdown" . }} {{ end }} 12 + <div class="flex items-center order-1 md:order-3">{{ block "newButton" . }} {{ end }}</div> 13 + <div class="flex items-center order-2 md:order-4">{{ template "notifications/fragments/bell" }}</div> 14 + <div class="flex items-center order-3 md:order-1">{{ block "search" . }} {{ end }}</div> 15 + <div class="hidden md:block md:order-2 w-px h-6 bg-gray-200 dark:bg-gray-700 self-center"></div> 16 + <div class="flex items-center order-4 md:order-5">{{ block "profileDropdown" . }} {{ end }}</div> 16 17 {{ else }} 17 18 <a href="/login">login</a> 18 19 <span class="text-gray-500 dark:text-gray-400">or</span> ··· 43 44 </details> 44 45 {{ end }} 45 46 46 - {{ define "searchButton" }} 47 - <a href="/search">{{ i "search" "size-5 text-gray-500 dark:text-gray-400" }}</a> 47 + {{ define "search" }} 48 + <div class="relative hidden md:block" id="topbar-search-container"> 49 + <div id="topbar-search-box" class="relative flex items-center gap-2 px-2 pr-1.5 py-4 max-h-[30px] md:w-80 lg:w-96 border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-900 focus-within:z-[51] before:content-[''] before:absolute before:inset-0 before:rounded-md before:invisible focus-within:before:visible before:ring-2 before:ring-transparent before:ring-offset-transparent before:ring-offset-1 before:pointer-events-none focus-within:before:ring-gray-300 dark:focus-within:before:ring-gray-600"> 50 + {{ i "search" "size-4 text-gray-400" }} 51 + <input 52 + type="text" 53 + id="topbar-search-input" 54 + name="q" 55 + placeholder="search..." 56 + autocomplete="off" 57 + class="flex-1 border-none bg-transparent p-0 focus:outline-none focus:[box-shadow:none] text-sm text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-600 peer" 58 + hx-get="/search/quick" 59 + hx-trigger="input changed delay:10ms" 60 + hx-target="#topbar-search-results" 61 + hx-swap="innerHTML" 62 + hx-indicator="#topbar-search-indicator" 63 + /> 64 + <kbd class="pointer-events-none flex items-center text-xs border border-gray-200 dark:border-gray-600 rounded border-b-2 px-1 font-sans leading-5 text-gray-400 dark:text-gray-500 peer-focus:hidden peer-[:not(:placeholder-shown)]:hidden">⌘K</kbd> 65 + <span id="topbar-search-indicator" class="shrink-0"> 66 + {{ i "loader-circle" "size-4 text-gray-400 animate-spin" }} 67 + </span> 68 + <button 69 + type="button" 70 + class="hidden peer-[:not(:placeholder-shown)]:block text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" 71 + onclick="const i=document.getElementById('topbar-search-input');i.value='';htmx.trigger(i,'input');i.focus()" 72 + >{{ i "x" "size-4" }}</button> 73 + </div> 74 + <div id="topbar-search-results" class="absolute w-full z-50"></div> 75 + </div> 76 + 77 + <button id="mobile-search-btn" class="md:hidden" type="button" data-action="open-mobile-search"> 78 + {{ i "search" "size-5 text-gray-500 dark:text-gray-400" }} 79 + </button> 80 + 81 + <div id="mobile-search-overlay" 82 + class="md:hidden fixed inset-x-0 top-0 h-dvh z-50 83 + bg-white dark:bg-gray-900 84 + flex flex-col 85 + opacity-0 pointer-events-none 86 + transition-opacity duration-150 87 + pt-[env(safe-area-inset-top)]" 88 + aria-hidden="true"> 89 + <div class="flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-gray-700 shrink-0"> 90 + {{ i "search" "size-4 text-gray-400 shrink-0" }} 91 + <input 92 + type="text" 93 + id="mobile-search-input" 94 + name="q" 95 + placeholder="search..." 96 + autocomplete="off" 97 + inputmode="search" 98 + class="flex-1 min-w-0 border-none bg-transparent p-0 focus:outline-none focus:[box-shadow:none] text-[16px] text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-600 peer" 99 + hx-get="/search/quick/mobile" 100 + hx-trigger="input changed delay:10ms" 101 + hx-target="#mobile-search-results" 102 + hx-swap="innerHTML" 103 + hx-indicator="#mobile-search-indicator" 104 + /> 105 + <span id="mobile-search-indicator" class="shrink-0"> 106 + {{ i "loader-circle" "size-4 text-gray-400 animate-spin" }} 107 + </span> 108 + <button type="button" class="p-1 -mr-1 hit-area hit-area-4" data-action="close-mobile-search"> 109 + {{ i "x" "size-5 text-gray-400" }} 110 + </button> 111 + </div> 112 + <div id="mobile-search-results" class="flex-1 flex flex-col overflow-hidden pb-[env(safe-area-inset-bottom)]"></div> 113 + <div id="mobile-search-spacer" class="bg-gray-50 dark:bg-gray-800 hidden"></div> 114 + </div> 115 + <style> 116 + #topbar-search-indicator, 117 + #mobile-search-indicator { 118 + display: none; 119 + opacity: 0; 120 + transition-property: opacity, display; 121 + transition-duration: 150ms, 150ms; 122 + transition-timing-function: ease-out; 123 + transition-behavior: allow-discrete; 124 + } 125 + #topbar-search-indicator.htmx-request, 126 + #mobile-search-indicator.htmx-request { 127 + display: block; 128 + opacity: 1; 129 + transition-duration: 0s, 0s; 130 + } 131 + </style> 48 132 {{ end }} 49 133 50 134 {{ define "profileDropdown" }}
+45
appview/pages/templates/search/fragments/quick.html
··· 1 + {{define "search/fragments/quick"}} 2 + {{- if .Repos -}} 3 + <div class="bg-white dark:bg-gray-900 border border-t-0 border-gray-200 dark:border-gray-700 rounded-b-md shadow-xl overflow-hidden"> 4 + <div class="max-h-[70vh] overflow-y-auto overscroll-contain divide-y divide-gray-100 dark:divide-gray-700"> 5 + {{ template "search/fragments/quickPage" . }} 6 + </div> 7 + {{ template "search/fragments/quickFooter" . }} 8 + </div> 9 + {{- else if .Query -}} 10 + <div class="bg-white dark:bg-gray-900 border border-t-0 border-gray-200 dark:border-gray-700 rounded-b-md shadow-xl overflow-hidden px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400"> 11 + no results for "{{ .Query }}" 12 + </div> 13 + {{- end -}} 14 + {{end}} 15 + 16 + {{define "search/fragments/quickMobile"}} 17 + {{- if .Repos -}} 18 + <div class="flex flex-col flex-1 min-h-0 overscroll-contain"> 19 + <div class="flex-1 overflow-y-auto overscroll-contain [&>*+*]:border-t [&>*+*]:border-gray-200 dark:[&>*+*]:border-gray-700"> 20 + {{ template "search/fragments/quickPage" . }} 21 + </div> 22 + {{ template "search/fragments/quickFooter" . }} 23 + </div> 24 + {{- else if .Query -}} 25 + <div class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400"> 26 + no results for "{{ .Query }}" 27 + </div> 28 + {{- end -}} 29 + {{end}} 30 + 31 + {{define "search/fragments/quickPage"}} 32 + {{- range .Repos -}} 33 + {{ template "user/fragments/repoCard" (list $ . true false (dict) true) }} 34 + {{- end -}} 35 + {{end}} 36 + 37 + {{define "search/fragments/quickFooter"}} 38 + <div data-results-footer class="bg-gray-50 dark:bg-gray-800 px-4 py-2 flex justify-between items-center text-sm text-gray-600 dark:text-gray-400 border-t border-gray-100 dark:border-gray-700"> 39 + <span>showing {{ len .Repos }} of {{ .Total }}</span> 40 + <a href="/search?q={{ urlquery .Query }}" 41 + class="flex items-center gap-1 hover:text-gray-600 dark:hover:text-gray-300 no-underline hover:no-underline"> 42 + all results {{ i "arrow-right" "size-4" }} 43 + </a> 44 + </div> 45 + {{end}}
+7 -3
appview/pages/templates/user/fragments/repoCard.html
··· 5 5 {{ $fullName := index . 2 }} 6 6 {{ $starButton := false }} 7 7 {{ $starData := dict }} 8 + {{ $compact := false }} 8 9 {{ if gt (len .) 3 }} 9 10 {{ $starButton = index . 3 }} 10 11 {{ if gt (len .) 4 }} 11 12 {{ $starData = index . 4 }} 13 + {{ if gt (len .) 5 }} 14 + {{ $compact = index . 5 }} 15 + {{ end }} 12 16 {{ end }} 13 17 {{ end }} 14 18 15 19 {{ with $repo }} 16 - <div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm bg-white dark:bg-gray-800 min-h-32"> 20 + <div class="{{ if not $compact }} min-h-32 {{ end }} {{ if $compact }} focus-within:bg-gray-100 dark:focus-within:bg-gray-800/80 {{ end }} py-4 px-6 gap-1 flex flex-col drop-shadow-sm bg-white dark:bg-gray-800"> 17 21 <div class="font-medium dark:text-white flex items-center justify-between"> 18 22 <div class="flex items-center min-w-0 flex-1 mr-2"> 19 23 {{ if .Source }} ··· 23 27 {{ end }} 24 28 {{ $repoOwner := resolve .Did }} 25 29 {{- if $fullName -}} 26 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Name }}</a> 30 + <a href="/{{ $repoOwner }}/{{ .Name }}" data-nav-result class="truncate min-w-0 {{ if $compact }} focus:outline-none {{ end }}">{{ $repoOwner }}/{{ .Name }}</a> 27 31 {{- else -}} 28 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ .Name }}</a> 32 + <a href="/{{ $repoOwner }}/{{ .Name }}" data-nav-result class="truncate min-w-0 {{ if $compact }} focus:outline-none {{ end }}">{{ .Name }}</a> 29 33 {{- end -}} 30 34 </div> 31 35 {{ if and $starButton $root.LoggedInUser }}
+2
appview/state/router.go
··· 165 165 r.Post("/logout", s.Logout) 166 166 167 167 r.With(middleware.Paginate).Get("/search", s.Search) 168 + r.With(middleware.AuthMiddleware(s.oauth)).Get("/search/quick", s.SearchQuick) 169 + r.With(middleware.AuthMiddleware(s.oauth)).Get("/search/quick/mobile", s.SearchQuickMobile) 168 170 169 171 r.Post("/account/switch", s.SwitchAccount) 170 172 r.With(middleware.AuthMiddleware(s.oauth)).Delete("/account/{did}", s.RemoveAccount)
+79 -10
appview/state/search.go
··· 1 1 package state 2 2 3 3 import ( 4 + "cmp" 4 5 "net/http" 6 + "slices" 5 7 "strings" 6 8 "time" 7 9 ··· 68 70 return 69 71 } 70 72 71 - // sort repos to match search result order (by relevance) 72 - repoMap := make(map[int64]models.Repo, len(repos)) 73 - for _, repo := range repos { 74 - repoMap[repo.Id] = repo 75 - } 76 - repos = make([]models.Repo, 0, len(res.Hits)) 77 - for _, id := range res.Hits { 78 - if repo, ok := repoMap[id]; ok { 79 - repos = append(repos, repo) 80 - } 73 + hitIdx := make(map[int64]int, len(res.Hits)) 74 + for i, id := range res.Hits { 75 + hitIdx[id] = i 81 76 } 77 + slices.SortFunc(repos, func(a, b models.Repo) int { 78 + return cmp.Compare(hitIdx[a.Id], hitIdx[b.Id]) 79 + }) 82 80 } 83 81 resultCount = int(res.Total) 84 82 ··· 158 156 } 159 157 } 160 158 159 + func (s *State) SearchQuick(w http.ResponseWriter, r *http.Request) { 160 + s.searchQuick(w, r, false) 161 + } 162 + 163 + func (s *State) SearchQuickMobile(w http.ResponseWriter, r *http.Request) { 164 + s.searchQuick(w, r, true) 165 + } 166 + 167 + func (s *State) searchQuick(w http.ResponseWriter, r *http.Request, mobile bool) { 168 + rawQuery := r.URL.Query().Get("q") 169 + if rawQuery == "" { 170 + w.WriteHeader(http.StatusOK) 171 + return 172 + } 173 + 174 + const pageSize = 5 175 + 176 + query := searchquery.Parse(rawQuery) 177 + tf := searchquery.ExtractTextFilters(query) 178 + 179 + searchOpts := models.RepoSearchOptions{ 180 + Keywords: tf.Keywords, 181 + Phrases: tf.Phrases, 182 + NegatedKeywords: tf.NegatedKeywords, 183 + NegatedPhrases: tf.NegatedPhrases, 184 + Page: pagination.Page{Limit: pageSize}, 185 + } 186 + 187 + var repos []models.Repo 188 + var total int 189 + 190 + if searchOpts.HasSearchFilters() { 191 + res, err := s.indexer.Repos.Search(r.Context(), searchOpts) 192 + if err != nil { 193 + s.logger.Error("failed quick search", "err", err) 194 + http.Error(w, "search failed", http.StatusInternalServerError) 195 + return 196 + } 197 + total = int(res.Total) 198 + if len(res.Hits) > 0 { 199 + repos, err = db.GetRepos(s.db, orm.FilterIn("id", res.Hits)) 200 + if err != nil { 201 + s.logger.Error("failed to get repos for quick search", "err", err) 202 + http.Error(w, "search failed", http.StatusInternalServerError) 203 + return 204 + } 205 + hitIdx := make(map[int64]int, len(res.Hits)) 206 + for i, id := range res.Hits { 207 + hitIdx[id] = i 208 + } 209 + slices.SortFunc(repos, func(a, b models.Repo) int { 210 + return cmp.Compare(hitIdx[a.Id], hitIdx[b.Id]) 211 + }) 212 + } 213 + } 214 + 215 + params := pages.SearchQuickParams{ 216 + Repos: repos, 217 + Query: rawQuery, 218 + Total: total, 219 + } 220 + 221 + render := s.pages.SearchQuick 222 + if mobile { 223 + render = s.pages.SearchQuickMobile 224 + } 225 + if err := render(w, params); err != nil { 226 + s.logger.Error("failed to render quick search", "err", err) 227 + } 228 + } 229 + 161 230 // parseSortParam parses sort parameter like "stars-desc" or "created-asc" 162 231 func parseSortParam(sortParam string) (string, bool) { 163 232 defaultSort := func() (string, bool) { return "relevance", true }

History

3 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
appview: add topbar quick-search
merge conflicts detected
expand
  • .gitignore:6
  • appview/pages/pages.go:1674
  • appview/pages/templates/layouts/base.html:3
  • appview/pages/templates/layouts/fragments/topbar.html:9
  • appview/pages/templates/user/fragments/repoCard.html:5
  • appview/state/router.go:165
  • appview/state/search.go:1
expand 0 comments
5 commits
expand
appview: add topbar quick-search
appview: improve topbar quick-search ux
appview: modernize topbar-search.js
appview: use shared repoCard in quick-search results
appview: make quick-search more responsive
expand 0 comments
eti.tf submitted #0
1 commit
expand
appview: add topbar quick-search
expand 0 comments