Go html/template doesn't support function calls as template names in
{{template (func .Arg) .}}. Instead, precompute TemplateName in
FromResponse and use {{template .TemplateName .}} in the template.
219 lines
5.7 KiB
Go
219 lines
5.7 KiB
Go
package views
|
|
|
|
import (
|
|
"embed"
|
|
"html/template"
|
|
"io/fs"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/metamorphosis-dev/kafka/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
|
|
ShowHeader bool
|
|
}
|
|
|
|
// ResultView is a template-friendly wrapper around a MainResult.
|
|
type ResultView struct {
|
|
contracts.MainResult
|
|
// TemplateName is the actual template to dispatch to, computed from Template.
|
|
// "videos" maps to "video_item", everything else maps to "result_item".
|
|
TemplateName string
|
|
}
|
|
|
|
// 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", "video_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", "video_item.html",
|
|
))
|
|
}
|
|
|
|
// StaticFS returns the embedded static file system for serving CSS/JS/images.
|
|
func StaticFS() (fs.FS, error) {
|
|
return fs.Sub(staticFS, "static")
|
|
}
|
|
|
|
// OpenSearchXML returns the OpenSearch description XML with {baseUrl}
|
|
// replaced by the provided base URL.
|
|
func OpenSearchXML(baseURL string) ([]byte, error) {
|
|
tmplFS, _ := fs.Sub(templatesFS, "templates")
|
|
data, err := fs.ReadFile(tmplFS, "opensearch.xml")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := strings.ReplaceAll(string(data), "{baseUrl}", baseURL)
|
|
return []byte(result), nil
|
|
}
|
|
|
|
// 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 {
|
|
tmplName := "result_item"
|
|
if r.Template == "videos" {
|
|
tmplName = "video_item"
|
|
}
|
|
pd.Results[i] = ResultView{MainResult: r, TemplateName: tmplName}
|
|
}
|
|
|
|
// 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{ShowHeader: true})
|
|
}
|
|
|
|
// 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")
|
|
data.ShowHeader = true
|
|
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
|