kafka/internal/views/views.go
Franz Kafka 4a6559be62 fix: add Thumbnail field and video result template
MainResult: add Thumbnail field (used by YouTube, images, etc.)
video_item.html: new partial for video results with thumbnail display
views.go: add templateForResult func + video_item.html to template parse
results_inner.html: dispatch to video_item when Template="videos"
kafka.css: add .video-result flex layout with thumbnail styling
2026-03-22 02:06:41 +00:00

218 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 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,
// templateForResult returns the template name to use for a result.
// Defaults to "result_item"; use "video_item" for video results.
"templateForResult": func(tmpl string) string {
if tmpl == "videos" {
return "video_item"
}
return "result_item"
},
}
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 {
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{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