feat: HTMX + Go Templates HTML frontend
- Add internal/views/ package with embedded templates and static files - Go html/template with SearXNG-compatible CSS class names - Dark mode via prefers-color-scheme, responsive layout, print styles - HTMX integration: - Debounced instant search (500ms) on the search input - Form submission targets #results via hx-post - Pagination buttons are HTMX-powered (swap results div only) - HX-Request header detection for fragment vs full page rendering - Template structure: - base.html: full page layout with HTMX script, favicon, CSS - index.html: homepage with centered search box - results.html: full results page (wraps base + results_inner) - results_inner.html: results fragment (HTMX partial + sidebar + pagination) - result_item.html: reusable result article partial - Smart format detection: browser requests (Accept: text/html) default to HTML, API clients default to JSON - Static files served at /static/ from embedded FS (CSS, favicon SVG) - Index route at GET / - Empty query on HTML format redirects to homepage - Custom CSS (gosearch.css): clean, minimal, privacy-respecting aesthetic with light/dark mode, responsive breakpoints, print stylesheet - Add views package tests
This commit is contained in:
parent
ebeaeeef21
commit
28b61ff251
12 changed files with 1013 additions and 8 deletions
196
internal/views/views.go
Normal file
196
internal/views/views.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package views
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ashie/gosearch/internal/contracts"
|
||||
)
|
||||
|
||||
//go:embed all:templates
|
||||
var templatesFS embed.FS
|
||||
|
||||
//go:embed all:static
|
||||
var staticFS embed.FS
|
||||
|
||||
// PageData holds all data passed to templates.
|
||||
type PageData struct {
|
||||
Query string
|
||||
Pageno int
|
||||
PrevPage int
|
||||
NextPage int
|
||||
HasNext bool
|
||||
NumberOfResults int
|
||||
Results []ResultView
|
||||
Answers []string
|
||||
Corrections []string
|
||||
Suggestions []string
|
||||
Infoboxes []InfoboxView
|
||||
UnresponsiveEngines [][2]string
|
||||
PageNumbers []PageNumber
|
||||
}
|
||||
|
||||
// ResultView is a template-friendly wrapper around a MainResult.
|
||||
type ResultView contracts.MainResult
|
||||
|
||||
// PageNumber represents a numbered pagination button.
|
||||
type PageNumber struct {
|
||||
Num int
|
||||
IsCurrent bool
|
||||
}
|
||||
|
||||
// InfoboxView is a template-friendly infobox.
|
||||
type InfoboxView struct {
|
||||
Title string
|
||||
Content string
|
||||
ImgSrc string
|
||||
}
|
||||
|
||||
var (
|
||||
tmplFull *template.Template
|
||||
tmplIndex *template.Template
|
||||
tmplFragment *template.Template
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Strip the leading "templates/" prefix from embed paths.
|
||||
tmplFS, _ := fs.Sub(templatesFS, "templates")
|
||||
|
||||
funcMap := template.FuncMap{
|
||||
"urlquery": template.URLQueryEscaper,
|
||||
}
|
||||
|
||||
tmplFull = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
|
||||
"base.html", "results.html", "results_inner.html", "result_item.html",
|
||||
))
|
||||
tmplIndex = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
|
||||
"base.html", "index.html",
|
||||
))
|
||||
tmplFragment = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
|
||||
"results_inner.html", "result_item.html",
|
||||
))
|
||||
}
|
||||
|
||||
// StaticFS returns the embedded static file system for serving CSS/JS/images.
|
||||
func StaticFS() (fs.FS, error) {
|
||||
return fs.Sub(staticFS, "static")
|
||||
}
|
||||
|
||||
// FromResponse builds PageData from a search response and request params.
|
||||
func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageData {
|
||||
pd := PageData{
|
||||
Query: query,
|
||||
Pageno: pageno,
|
||||
NumberOfResults: resp.NumberOfResults,
|
||||
UnresponsiveEngines: resp.UnresponsiveEngines,
|
||||
}
|
||||
|
||||
// Convert results.
|
||||
pd.Results = make([]ResultView, len(resp.Results))
|
||||
for i, r := range resp.Results {
|
||||
pd.Results[i] = ResultView(r)
|
||||
}
|
||||
|
||||
// Convert answers (they're map[string]any — extract string values).
|
||||
for _, a := range resp.Answers {
|
||||
if s, ok := a["answer"].(string); ok && s != "" {
|
||||
pd.Answers = append(pd.Answers, s)
|
||||
}
|
||||
}
|
||||
|
||||
pd.Corrections = resp.Corrections
|
||||
pd.Suggestions = resp.Suggestions
|
||||
|
||||
// Convert infoboxes.
|
||||
for _, ib := range resp.Infoboxes {
|
||||
iv := InfoboxView{}
|
||||
if v, ok := ib["infobox"].(string); ok {
|
||||
iv.Content = v
|
||||
}
|
||||
if v, ok := ib["title"].(string); ok {
|
||||
iv.Title = v
|
||||
}
|
||||
if v, ok := ib["img_src"].(string); ok {
|
||||
iv.ImgSrc = v
|
||||
}
|
||||
if iv.Title != "" || iv.Content != "" {
|
||||
pd.Infoboxes = append(pd.Infoboxes, iv)
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination.
|
||||
pd.PrevPage = pageno - 1
|
||||
if pd.PrevPage < 1 {
|
||||
pd.PrevPage = 1
|
||||
}
|
||||
pd.NextPage = pageno + 1
|
||||
// Assume there are more results if we got results on this page.
|
||||
pd.HasNext = len(resp.Results) > 0
|
||||
|
||||
// Build page number list.
|
||||
pstart := 1
|
||||
pend := 10
|
||||
if pageno > 5 {
|
||||
pstart = pageno - 4
|
||||
pend = pageno + 5
|
||||
}
|
||||
if pstart < 1 {
|
||||
pstart = 1
|
||||
}
|
||||
for x := pstart; x <= pend; x++ {
|
||||
pd.PageNumbers = append(pd.PageNumbers, PageNumber{Num: x, IsCurrent: x == pageno})
|
||||
}
|
||||
|
||||
return pd
|
||||
}
|
||||
|
||||
// RenderIndex renders the homepage (search box only).
|
||||
func RenderIndex(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
return tmplIndex.ExecuteTemplate(w, "base", PageData{})
|
||||
}
|
||||
|
||||
// RenderSearch renders the full search results page (with base layout).
|
||||
func RenderSearch(w http.ResponseWriter, data PageData) error {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
return tmplFull.ExecuteTemplate(w, "base", data)
|
||||
}
|
||||
|
||||
// RenderSearchFragment renders only the results fragment for HTMX requests.
|
||||
func RenderSearchFragment(w http.ResponseWriter, data PageData) error {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
return tmplFragment.ExecuteTemplate(w, "results_inner", data)
|
||||
}
|
||||
|
||||
// IsHTMXRequest checks if the request is an HTMX partial request.
|
||||
func IsHTMXRequest(r *http.Request) bool {
|
||||
return r.Header.Get("HX-Request") == "true"
|
||||
}
|
||||
|
||||
// FormatQuery parses format, pageno, and q from an HTTP request.
|
||||
func FormatQuery(r *http.Request) (format string, pageno int, query string) {
|
||||
format = r.FormValue("format")
|
||||
if format == "" {
|
||||
format = "html"
|
||||
}
|
||||
pageno, _ = strconv.Atoi(r.FormValue("pageno"))
|
||||
if pageno < 1 {
|
||||
pageno = 1
|
||||
}
|
||||
query = strings.TrimSpace(r.FormValue("q"))
|
||||
return
|
||||
}
|
||||
|
||||
// RenderSearch decides between full page or fragment based on HTMX header.
|
||||
func RenderSearchAuto(w http.ResponseWriter, r *http.Request, data PageData) error {
|
||||
if IsHTMXRequest(r) {
|
||||
return RenderSearchFragment(w, data)
|
||||
}
|
||||
return RenderSearch(w, data)
|
||||
}
|
||||
|
||||
var _ = strconv.Itoa
|
||||
Loading…
Add table
Add a link
Reference in a new issue