kafka/internal/views/views.go
Franz Kafka 28b61ff251 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
2026-03-21 16:10:42 +00:00

196 lines
4.9 KiB
Go

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