feat(frontend): add three-column results layout with left sidebar navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ashisgreat22 2026-03-22 13:36:09 +01:00
parent 2e7075adf1
commit 0e79b729fe
3 changed files with 122 additions and 23 deletions

View file

@ -112,7 +112,8 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
} }
if req.Format == contracts.FormatHTML { if req.Format == contracts.FormatHTML {
pd := views.FromResponse(resp, req.Query, req.Pageno) pd := views.FromResponse(resp, req.Query, req.Pageno,
r.FormValue("category"), r.FormValue("time"), r.FormValue("type"))
if err := views.RenderSearchAuto(w, r, pd); err != nil { if err := views.RenderSearchAuto(w, r, pd); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }

View file

@ -1,32 +1,75 @@
{{define "title"}}{{if .Query}}{{.Query}} — {{end}}{{end}} {{define "title"}}{{if .Query}}{{.Query}} — {{end}}{{end}}
{{define "content"}} {{define "content"}}
<div class="results-layout"> <div class="results-layout">
<!-- Compact search bar --> <!-- Left Sidebar -->
<div class="search-compact"> <aside class="left-sidebar" id="left-sidebar">
<div class="search-box"> <nav class="sidebar-nav">
<form method="GET" action="/search" role="search" id="search-form"> <div class="sidebar-nav-title">Categories</div>
<input type="text" name="q" id="q" value="{{.Query}}" autocomplete="off" autofocus {{range .Categories}}
hx-get="/search" hx-target="#results" hx-trigger="keyup changed delay:500ms" hx-include="this"> <a href="/search?q={{$.Query | urlquery}}&category={{.}}" class="sidebar-nav-item {{if eq $.ActiveCategory .}}active{{end}}">
<button type="submit" class="search-box-submit" aria-label="Search"> <span class="sidebar-nav-item-icon">{{index $.CategoryIcons .}}</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> <span>{{.}}</span>
<circle cx="11" cy="11" r="8"/> </a>
<path d="m21 21-4.35-4.35"/> {{end}}
</svg> </nav>
</button>
</form>
</div>
</div>
<!-- Results --> <div class="sidebar-filters">
<div class="results-column" id="results"> <div class="sidebar-filter-group">
<div class="sidebar-filter-label">Time</div>
{{range .TimeFilters}}
<label class="sidebar-filter-option">
<input type="radio" name="time" value="{{.Value}}" {{if eq $.ActiveTime .Value}}checked{{end}}>
<span>{{.Label}}</span>
</label>
{{end}}
</div>
<div class="sidebar-filter-group">
<div class="sidebar-filter-label">Type</div>
{{range .TypeFilters}}
<label class="sidebar-filter-option">
<input type="radio" name="type" value="{{.Value}}" {{if eq $.ActiveType .Value}}checked{{end}}>
<span>{{.Label}}</span>
</label>
{{end}}
</div>
</div>
</aside>
<!-- Center Column -->
<div class="results-column">
<!-- Compact search bar -->
<div class="search-compact">
<div class="search-box">
<form method="GET" action="/search" role="search" id="search-form">
<input type="text" name="q" id="q" value="{{.Query}}" autocomplete="off" autofocus
hx-get="/search" hx-target="#results" hx-trigger="keyup changed delay:500ms" hx-include="this">
<button type="submit" class="search-box-submit" aria-label="Search">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.35-4.35"/>
</svg>
</button>
</form>
</div>
</div>
<!-- Mobile filter chips -->
<div class="mobile-filter-chips">
<a href="/search?q={{.Query | urlquery}}" class="mobile-filter-chip {{if not .ActiveCategory}}active{{end}}">All</a>
{{range .Categories}}
<a href="/search?q={{$.Query | urlquery}}&category={{.}}" class="mobile-filter-chip {{if eq $.ActiveCategory .}}active{{end}}">{{.}}</a>
{{end}}
</div>
<!-- Results inner -->
{{template "results_inner" .}} {{template "results_inner" .}}
</div> </div>
<!-- Sidebar --> <!-- Right Sidebar -->
<aside class="sidebar" id="sidebar"> <aside class="right-sidebar" id="sidebar">
{{if .Suggestions}} {{if .Suggestions}}
<div class="sidebar-card"> <div class="sidebar-card">
<div class="sidebar-title">Suggestions</div> <div class="sidebar-title">Related Searches</div>
<div class="suggestion-list"> <div class="suggestion-list">
{{range .Suggestions}}<span class="suggestion"><a href="/search?q={{. | urlquery}}">{{.}}</a></span>{{end}} {{range .Suggestions}}<span class="suggestion"><a href="/search?q={{. | urlquery}}">{{.}}</a></span>{{end}}
</div> </div>
@ -43,4 +86,4 @@
{{end}} {{end}}
</aside> </aside>
</div> </div>
{{end}} {{end}}

View file

@ -50,6 +50,15 @@ type PageData struct {
UnresponsiveEngines [][2]string UnresponsiveEngines [][2]string
PageNumbers []PageNumber PageNumbers []PageNumber
ShowHeader bool ShowHeader bool
// New fields for three-column layout
Categories []string
CategoryIcons map[string]string
DisabledCategories []string
ActiveCategory string
TimeFilters []FilterOption
TypeFilters []FilterOption
ActiveTime string
ActiveType string
} }
// ResultView is a template-friendly wrapper around a MainResult. // ResultView is a template-friendly wrapper around a MainResult.
@ -73,6 +82,12 @@ type InfoboxView struct {
ImgSrc string ImgSrc string
} }
// FilterOption represents a filter radio option for the sidebar.
type FilterOption struct {
Label string
Value string
}
var ( var (
tmplFull *template.Template tmplFull *template.Template
tmplIndex *template.Template tmplIndex *template.Template
@ -116,12 +131,52 @@ func OpenSearchXML(baseURL string) ([]byte, error) {
} }
// FromResponse builds PageData from a search response and request params. // FromResponse builds PageData from a search response and request params.
func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageData { func FromResponse(resp contracts.SearchResponse, query string, pageno int, activeCategory, activeTime, activeType string) PageData {
// Set defaults
if activeCategory == "" {
activeCategory = "all"
}
pd := PageData{ pd := PageData{
Query: query, Query: query,
Pageno: pageno, Pageno: pageno,
NumberOfResults: resp.NumberOfResults, NumberOfResults: resp.NumberOfResults,
UnresponsiveEngines: resp.UnresponsiveEngines, UnresponsiveEngines: resp.UnresponsiveEngines,
// New: categories with icons
Categories: []string{"all", "news", "images", "videos", "maps"},
DisabledCategories: []string{"shopping", "music", "weather"},
CategoryIcons: map[string]string{
"all": "🌐",
"news": "📰",
"images": "🖼️",
"videos": "🎬",
"maps": "🗺️",
"shopping": "🛒",
"music": "🎵",
"weather": "🌤️",
},
ActiveCategory: activeCategory,
// Time filters
TimeFilters: []FilterOption{
{Label: "Any time", Value: ""},
{Label: "Past hour", Value: "h"},
{Label: "Past 24 hours", Value: "d"},
{Label: "Past week", Value: "w"},
{Label: "Past month", Value: "m"},
{Label: "Past year", Value: "y"},
},
ActiveTime: activeTime,
// Type filters
TypeFilters: []FilterOption{
{Label: "All results", Value: ""},
{Label: "News", Value: "news"},
{Label: "Videos", Value: "video"},
{Label: "Images", Value: "image"},
},
ActiveType: activeType,
} }
// Convert results. // Convert results.