+ {{.Title}} +
+{{.Content}}
+ {{end}} + {{if .Engine}} +diff --git a/cmd/searxng-go/main.go b/cmd/searxng-go/main.go index 386014d..eb19226 100644 --- a/cmd/searxng-go/main.go +++ b/cmd/searxng-go/main.go @@ -3,6 +3,7 @@ package main import ( "flag" "fmt" + "io/fs" "log" "log/slog" "net/http" @@ -13,6 +14,7 @@ import ( "github.com/ashie/gosearch/internal/httpapi" "github.com/ashie/gosearch/internal/middleware" "github.com/ashie/gosearch/internal/search" + "github.com/ashie/gosearch/internal/views" ) func main() { @@ -38,8 +40,7 @@ func main() { defer searchCache.Close() // Seed env vars from config so existing engine/factory/planner code - // picks them up without changes. The config layer is the single source - // of truth; env vars remain as overrides via applyEnvOverrides. + // picks them up without changes. if len(cfg.Engines.LocalPorted) > 0 { os.Setenv("LOCAL_PORTED_ENGINES", cfg.LocalPortedCSV()) } @@ -59,9 +60,18 @@ func main() { h := httpapi.NewHandler(svc) mux := http.NewServeMux() + mux.HandleFunc("/", h.Index) mux.HandleFunc("/healthz", h.Healthz) mux.HandleFunc("/search", h.Search) + // Serve embedded static files (CSS, JS, images). + staticFS, err := views.StaticFS() + if err != nil { + log.Fatalf("failed to load static files: %v", err) + } + var subFS fs.FS = staticFS + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(subFS)))) + // Apply middleware: rate limiter → CORS → handler. var handler http.Handler = mux handler = middleware.CORS(middleware.CORSConfig{ diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index 0b31e9b..9df2a25 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -3,7 +3,9 @@ package httpapi import ( "net/http" + "github.com/ashie/gosearch/internal/contracts" "github.com/ashie/gosearch/internal/search" + "github.com/ashie/gosearch/internal/views" ) type Handler struct { @@ -20,22 +22,64 @@ func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("OK")) } +// Index renders the homepage with the search box. +func (h *Handler) Index(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + if err := views.RenderIndex(w); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { + // For HTML format with no query, redirect to homepage. + if r.FormValue("q") == "" && (r.FormValue("format") == "" || r.FormValue("format") == "html") { + http.Redirect(w, r, "/", http.StatusFound) + return + } + req, err := search.ParseSearchRequest(r) if err != nil { + // For HTML, render error on the results page. + if req.Format == contracts.FormatHTML || r.FormValue("format") == "html" { + pd := views.PageData{Query: r.FormValue("q")} + if views.IsHTMXRequest(r) { + views.RenderSearchFragment(w, pd) + } else { + views.RenderSearch(w, pd) + } + return + } http.Error(w, err.Error(), http.StatusBadRequest) return } resp, err := h.searchSvc.Search(r.Context(), req) if err != nil { + if req.Format == contracts.FormatHTML { + pd := views.PageData{Query: req.Query} + if views.IsHTMXRequest(r) { + views.RenderSearchFragment(w, pd) + } else { + views.RenderSearch(w, pd) + } + return + } http.Error(w, err.Error(), http.StatusInternalServerError) return } + if req.Format == contracts.FormatHTML { + pd := views.FromResponse(resp, req.Query, req.Pageno) + if err := views.RenderSearchAuto(w, r, pd); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + if err := search.WriteSearchResponse(w, req.Format, resp); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) - return } } - diff --git a/internal/search/request_params.go b/internal/search/request_params.go index 9e4fd55..1d48a04 100644 --- a/internal/search/request_params.go +++ b/internal/search/request_params.go @@ -18,11 +18,13 @@ func ParseSearchRequest(r *http.Request) (SearchRequest, error) { format := strings.ToLower(r.FormValue("format")) switch OutputFormat(format) { - case FormatJSON, FormatCSV, FormatRSS: + case FormatJSON, FormatCSV, FormatRSS, FormatHTML: + // explicit format — use as-is default: - // MVP: treat everything else as json, except `html` which we accept for compatibility. - if format == string(FormatHTML) { - // accepted, but not implemented by the server yet + // No format specified: default to HTML for browser requests, JSON for API clients. + accept := r.Header.Get("Accept") + if strings.Contains(accept, "text/html") { + format = string(FormatHTML) } else { format = string(FormatJSON) } diff --git a/internal/views/static/css/gosearch.css b/internal/views/static/css/gosearch.css new file mode 100644 index 0000000..53171ed --- /dev/null +++ b/internal/views/static/css/gosearch.css @@ -0,0 +1,459 @@ +/* gosearch — clean, minimal search engine CSS */ +/* Inspired by SearXNG's simple theme class conventions */ + +:root { + --color-base: #f5f5f5; + --color-base-font: #444; + --color-base-background: #fff; + --color-header-background: #f7f7f7; + --color-header-border: #ddd; + --color-search-border: #bbb; + --color-search-focus: #3498db; + --color-result-url: #1a0dab; + --color-result-url-visited: #609; + --color-result-content: #545454; + --color-result-title: #1a0dab; + --color-result-title-visited: #609; + --color-result-engine: #666; + --color-result-border: #eee; + --color-link: #3498db; + --color-link-visited: #609; + --color-sidebar-background: #f7f7f7; + --color-sidebar-border: #ddd; + --color-infobox-background: #f9f9f9; + --color-infobox-border: #ddd; + --color-pagination-current: #3498db; + --color-pagination-border: #ddd; + --color-error: #c0392b; + --color-error-background: #fdecea; + --color-suggestion: #666; + --color-footer: #888; + --color-btn-background: #fff; + --color-btn-border: #ddd; + --color-btn-hover: #eee; + --radius: 4px; + --max-width: 800px; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-base: #222; + --color-base-font: #dcdcdc; + --color-base-background: #2b2b2b; + --color-header-background: #333; + --color-header-border: #444; + --color-search-border: #555; + --color-search-focus: #5dade2; + --color-result-url: #8ab4f8; + --color-result-url-visited: #b39ddb; + --color-result-content: #b0b0b0; + --color-result-title: #8ab4f8; + --color-result-title-visited: #b39ddb; + --color-result-engine: #999; + --color-result-border: #3a3a3a; + --color-link: #5dade2; + --color-link-visited: #b39ddb; + --color-sidebar-background: #333; + --color-sidebar-border: #444; + --color-infobox-background: #333; + --color-infobox-border: #444; + --color-pagination-current: #5dade2; + --color-pagination-border: #444; + --color-error: #e74c3c; + --color-error-background: #3b1a1a; + --color-suggestion: #999; + --color-footer: #666; + --color-btn-background: #333; + --color-btn-border: #555; + --color-btn-hover: #444; + } +} + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + color: var(--color-base-font); + background: var(--color-base); + line-height: 1.6; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +main { + flex: 1; + max-width: var(--max-width); + width: 100%; + margin: 0 auto; + padding: 1rem; +} + +footer { + text-align: center; + padding: 1.5rem; + color: var(--color-footer); + font-size: 0.85rem; +} + +footer a { + color: var(--color-link); + text-decoration: none; +} + +footer a:hover { + text-decoration: underline; +} + +/* Index / Homepage */ +.index { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + text-align: center; +} + +.index .title h1 { + font-size: 2rem; + font-weight: 300; + margin-bottom: 2rem; + letter-spacing: 0.05em; +} + +/* Search form */ +#search { + width: 100%; + max-width: 600px; + margin-bottom: 2rem; +} + +#search form { + display: flex; + gap: 0.5rem; +} + +#search input[type="text"], +#q { + flex: 1; + padding: 0.7rem 1rem; + font-size: 1rem; + border: 1px solid var(--color-search-border); + border-radius: var(--radius); + background: var(--color-base-background); + color: var(--color-base-font); + outline: none; + transition: border-color 0.2s; +} + +#search input[type="text"]:focus, +#q:focus { + border-color: var(--color-search-focus); + box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.15); +} + +#search button[type="submit"] { + padding: 0.7rem 1.2rem; + font-size: 1rem; + border: 1px solid var(--color-search-border); + border-radius: var(--radius); + background: var(--color-btn-background); + color: var(--color-base-font); + cursor: pointer; + transition: background 0.2s; +} + +#search button[type="submit"]:hover { + background: var(--color-btn-hover); +} + +/* Results page search bar (compact) */ +.search_on_results #search { + max-width: 100%; + margin-bottom: 1rem; +} + +/* Results area */ +#results { + display: flex; + gap: 1.5rem; +} + +#sidebar { + flex: 0 0 200px; + font-size: 0.85rem; +} + +#sidebar p { + margin-bottom: 0.5rem; +} + +#urls { + flex: 1; + min-width: 0; +} + +/* Result count */ +#result_count { + margin-bottom: 1rem; +} + +/* Individual result */ +.result { + padding: 0.8rem 0; + border-bottom: 1px solid var(--color-result-border); + word-wrap: break-word; +} + +.result:last-child { + border-bottom: none; +} + +.result_header { + margin-bottom: 0.2rem; +} + +.result_header a { + font-size: 1.1rem; + font-weight: 400; + color: var(--color-result-title); + text-decoration: none; +} + +.result_header a:visited { + color: var(--color-result-title-visited); +} + +.result_header a:hover { + text-decoration: underline; +} + +.result_url { + font-size: 0.85rem; + color: var(--color-result-url); + margin-bottom: 0.2rem; +} + +.result_url a { + color: var(--color-result-url); + text-decoration: none; +} + +.result_url a:visited { + color: var(--color-result-url-visited); +} + +.result_content { + font-size: 0.9rem; + color: var(--color-result-content); + max-width: 600px; +} + +.result_content p { + margin: 0; +} + +.result_engine { + font-size: 0.75rem; + color: var(--color-result-engine); + margin-top: 0.3rem; +} + +.engine { + display: inline-block; + padding: 0.1rem 0.4rem; + background: var(--color-sidebar-background); + border: 1px solid var(--color-sidebar-border); + border-radius: 2px; + font-size: 0.7rem; + color: var(--color-result-engine); +} + +/* No results */ +.no_results { + text-align: center; + padding: 3rem 1rem; + color: var(--color-suggestion); +} + +/* Suggestions */ +#suggestions { + margin-bottom: 1rem; +} + +.suggestion { + display: inline-block; + margin: 0.2rem; +} + +.suggestion a { + display: inline-block; + padding: 0.3rem 0.6rem; + font-size: 0.85rem; + border: 1px solid var(--color-pagination-border); + border-radius: var(--radius); + color: var(--color-link); + text-decoration: none; + background: var(--color-btn-background); + transition: background 0.2s; +} + +.suggestion a:hover { + background: var(--color-btn-hover); +} + +/* Infoboxes */ +#infoboxes { + margin-bottom: 1rem; +} + +.infobox { + background: var(--color-infobox-background); + border: 1px solid var(--color-infobox-border); + border-radius: var(--radius); + padding: 0.8rem; + margin-bottom: 0.5rem; +} + +.infobox .title { + font-weight: 600; + margin-bottom: 0.5rem; +} + +/* Errors */ +.dialog-error { + background: var(--color-error-background); + color: var(--color-error); + border: 1px solid var(--color-error); + border-radius: var(--radius); + padding: 0.8rem 1rem; + margin-bottom: 1rem; +} + +/* Unresponsive engines */ +.unresponsive_engines { + font-size: 0.8rem; + color: var(--color-suggestion); + margin-top: 0.5rem; +} + +.unresponsive_engines li { + margin: 0.1rem 0; +} + +/* Corrections */ +.correction { + font-size: 0.9rem; + margin-bottom: 0.5rem; +} + +/* Pagination */ +#pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 0.3rem; + padding: 1.5rem 0; + flex-wrap: wrap; +} + +#pagination button, +#pagination .page_number, +#pagination .page_number_current { + padding: 0.4rem 0.8rem; + font-size: 0.9rem; + border: 1px solid var(--color-pagination-border); + border-radius: var(--radius); + background: var(--color-btn-background); + color: var(--color-base-font); + cursor: pointer; + text-decoration: none; + transition: background 0.2s; +} + +#pagination button:hover, +#pagination .page_number:hover { + background: var(--color-btn-hover); +} + +#pagination .page_number_current { + background: var(--color-pagination-current); + color: #fff; + border-color: var(--color-pagination-current); + cursor: default; +} + +.previous_page, .next_page { + font-weight: 500; +} + +/* Back to top */ +#backToTop { + text-align: center; + margin: 1rem 0; +} + +#backToTop a { + color: var(--color-link); + text-decoration: none; + font-size: 0.85rem; +} + +/* HTMX loading indicator */ +.htmx-indicator { + display: none; + text-align: center; + padding: 2rem; + color: var(--color-suggestion); +} + +.htmx-request .htmx-indicator, +.htmx-request.htmx-indicator { + display: block; +} + +/* Responsive */ +@media (max-width: 768px) { + #results { + flex-direction: column-reverse; + } + + #sidebar { + flex: none; + border-top: 1px solid var(--color-sidebar-border); + padding-top: 0.5rem; + } + + .index .title h1 { + font-size: 1.5rem; + } + + main { + padding: 0.5rem; + } +} + +/* Print */ +@media print { + footer, #pagination, #search button, #backToTop, .htmx-indicator { + display: none; + } + + body { + background: #fff; + color: #000; + } + + .result a { + color: #000; + } +} diff --git a/internal/views/static/img/favicon.svg b/internal/views/static/img/favicon.svg new file mode 100644 index 0000000..d4f841a --- /dev/null +++ b/internal/views/static/img/favicon.svg @@ -0,0 +1,4 @@ + diff --git a/internal/views/templates/base.html b/internal/views/templates/base.html new file mode 100644 index 0000000..9992d3c --- /dev/null +++ b/internal/views/templates/base.html @@ -0,0 +1,25 @@ +{{define "base"}} + + +
+ + + + + +{{.Content}}
+ {{end}} + {{if .Engine}} +No results found.
+ {{if .Query}}Try different keywords or check your spelling.
{{end}} +