Compare commits
29 commits
471b9798e1
...
f1436310eb
| Author | SHA1 | Date | |
|---|---|---|---|
| f1436310eb | |||
| b499db68f7 | |||
| f0a65e2b8c | |||
| 7d3c82214b | |||
| 8a4a606dd6 | |||
| 841526276e | |||
| a03945b0e4 | |||
| 281c327f60 | |||
| 4a6559be62 | |||
| af23a63a73 | |||
| a9ea99c104 | |||
| b7f8a87edb | |||
| 277db9463e | |||
| 84777211f8 | |||
| 8e53a8b11d | |||
| 1906723859 | |||
| 2785b84939 | |||
| 4fe78c69ce | |||
| 11480dacdf | |||
| 4c52c77460 | |||
| 2c9d1c3543 | |||
| bfed53ae33 | |||
| a7f594b7fa | |||
| 1689cab9bd | |||
| 31fdd5e06f | |||
| 4be9cf2725 | |||
| fcd9be16df | |||
| 4482cb4dde | |||
| a2f8077669 |
35 changed files with 2240 additions and 96 deletions
|
|
@ -16,7 +16,7 @@ jobs:
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: https://github.com/actions/setup-go@v5
|
uses: https://github.com/actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24'
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: go test -race -v ./...
|
run: go test -race -v ./...
|
||||||
|
|
|
||||||
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
kafka is a privacy-respecting metasearch engine written in Go. It provides a SearXNG-compatible `/search` API and an HTML frontend (HTMX + Go templates). 9 engines are implemented natively in Go; unlisted engines can be proxied to an upstream SearXNG instance. Responses from multiple engines are merged into a single JSON/CSV/RSS/HTML response.
|
kafka is a privacy-respecting metasearch engine written in Go. It provides a SearXNG-compatible `/search` API and an HTML frontend (HTMX + Go templates). 9 engines are implemented natively in Go; unlisted engines can be proxied to an upstream metasearch instance. Responses from multiple engines are merged into a single JSON/CSV/RSS/HTML response.
|
||||||
|
|
||||||
## Build & Run Commands
|
## Build & Run Commands
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@ go test -run TestWikipedia ./internal/engines/
|
||||||
go test -v ./internal/engines/
|
go test -v ./internal/engines/
|
||||||
|
|
||||||
# Run the server (requires config.toml)
|
# Run the server (requires config.toml)
|
||||||
go run ./cmd/searxng-go -config config.toml
|
go run ./cmd/kafka -config config.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
There is no Makefile. There is no linter configured.
|
There is no Makefile. There is no linter configured.
|
||||||
|
|
@ -37,12 +37,13 @@ There is no Makefile. There is no linter configured.
|
||||||
- `internal/config` — TOML-based configuration with env var fallbacks. `Load(path)` reads `config.toml`; env vars override zero-value fields. See `config.example.toml` for all settings.
|
- `internal/config` — TOML-based configuration with env var fallbacks. `Load(path)` reads `config.toml`; env vars override zero-value fields. See `config.example.toml` for all settings.
|
||||||
- `internal/engines` — `Engine` interface and all 9 Go-native implementations. `factory.go` registers engines via `NewDefaultPortedEngines()`. `planner.go` routes engines to local or upstream based on `LOCAL_PORTED_ENGINES` env var.
|
- `internal/engines` — `Engine` interface and all 9 Go-native implementations. `factory.go` registers engines via `NewDefaultPortedEngines()`. `planner.go` routes engines to local or upstream based on `LOCAL_PORTED_ENGINES` env var.
|
||||||
- `internal/search` — `Service` orchestrates the pipeline: cache check, planning, parallel engine execution via goroutines/WaitGroup, upstream proxying, response merging. Individual engine failures are reported as `unresponsive_engines` rather than aborting the search. Qwant has fallback logic to upstream on empty results.
|
- `internal/search` — `Service` orchestrates the pipeline: cache check, planning, parallel engine execution via goroutines/WaitGroup, upstream proxying, response merging. Individual engine failures are reported as `unresponsive_engines` rather than aborting the search. Qwant has fallback logic to upstream on empty results.
|
||||||
- `internal/httpapi` — HTTP handlers for `/`, `/search`, `/healthz`, `/opensearch.xml`. Detects HTMX requests via `HX-Request` header to return fragments instead of full pages.
|
- `internal/autocomplete` — Fetches search suggestions. Proxies to upstream `/autocompleter` if configured, falls back to Wikipedia OpenSearch API otherwise.
|
||||||
- `internal/upstream` — Client that proxies requests to an upstream SearXNG instance via POST.
|
- `internal/httpapi` — HTTP handlers for `/`, `/search`, `/autocompleter`, `/healthz`, `/opensearch.xml`. Detects HTMX requests via `HX-Request` header to return fragments instead of full pages.
|
||||||
|
- `internal/upstream` — Client that proxies requests to an upstream metasearch instance via POST.
|
||||||
- `internal/cache` — Valkey/Redis-backed cache with SHA-256 cache keys. No-op if unconfigured.
|
- `internal/cache` — Valkey/Redis-backed cache with SHA-256 cache keys. No-op if unconfigured.
|
||||||
- `internal/middleware` — Three rate limiters (per-IP sliding window, burst+sustained, global) and CORS. All disabled by default.
|
- `internal/middleware` — Three rate limiters (per-IP sliding window, burst+sustained, global) and CORS. All disabled by default.
|
||||||
- `internal/views` — HTML templates and static files embedded via `//go:embed`. Renders full pages or HTMX fragments. Templates: `base.html`, `index.html`, `results.html`, `results_inner.html`, `result_item.html`.
|
- `internal/views` — HTML templates and static files embedded via `//go:embed`. Renders full pages or HTMX fragments. Templates: `base.html`, `index.html`, `results.html`, `results_inner.html`, `result_item.html`.
|
||||||
- `cmd/searxng-go` — Entry point. Loads TOML config, seeds env vars for engine code, wires up middleware chain, starts HTTP server.
|
- `cmd/kafka` — Entry point. Loads TOML config, seeds env vars for engine code, wires up middleware chain, starts HTTP server.
|
||||||
|
|
||||||
**Engine interface** (`internal/engines/engine.go`):
|
**Engine interface** (`internal/engines/engine.go`):
|
||||||
```go
|
```go
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ RUN go mod download
|
||||||
|
|
||||||
# Copy source and build
|
# Copy source and build
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /kafka ./cmd/searxng-go
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /kafka ./cmd/kafka
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
FROM alpine:3.21
|
FROM alpine:3.21
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -24,8 +24,8 @@ A privacy-respecting, open metasearch engine written in Go. SearXNG-compatible A
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.ashisgreat.xyz/penal-colony/gosearch.git
|
git clone https://git.ashisgreat.xyz/penal-colony/gosearch.git
|
||||||
cd kafka
|
cd kafka
|
||||||
go build ./cmd/searxng-go
|
go build ./cmd/kafka
|
||||||
./searxng-go -config config.toml
|
./kafka -config config.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
|
@ -76,7 +76,7 @@ sudo nixos-rebuild switch --flake .#
|
||||||
```bash
|
```bash
|
||||||
nix develop
|
nix develop
|
||||||
go test ./...
|
go test ./...
|
||||||
go run ./cmd/searxng-go -config config.toml
|
go run ./cmd/kafka -config config.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
@ -138,7 +138,7 @@ Copy `config.example.toml` to `config.toml` and edit. All settings can also be o
|
||||||
### Key Sections
|
### Key Sections
|
||||||
|
|
||||||
- **`[server]`** — port, timeout, public base URL for OpenSearch
|
- **`[server]`** — port, timeout, public base URL for OpenSearch
|
||||||
- **`[upstream]`** — optional upstream SearXNG proxy for unported engines
|
- **`[upstream]`** — optional upstream metasearch proxy for unported engines
|
||||||
- **`[engines]`** — which engines run locally, engine-specific settings
|
- **`[engines]`** — which engines run locally, engine-specific settings
|
||||||
- **`[cache]`** — Valkey/Redis address, password, TTL
|
- **`[cache]`** — Valkey/Redis address, password, TTL
|
||||||
- **`[cors]`** — allowed origins and methods
|
- **`[cors]`** — allowed origins and methods
|
||||||
|
|
@ -152,7 +152,7 @@ Copy `config.example.toml` to `config.toml` and edit. All settings can also be o
|
||||||
|---|---|
|
|---|---|
|
||||||
| `PORT` | Listen port (default: 8080) |
|
| `PORT` | Listen port (default: 8080) |
|
||||||
| `BASE_URL` | Public URL for OpenSearch XML |
|
| `BASE_URL` | Public URL for OpenSearch XML |
|
||||||
| `UPSTREAM_SEARXNG_URL` | Upstream SearXNG instance URL |
|
| `UPSTREAM_SEARXNG_URL` | Upstream instance URL |
|
||||||
| `LOCAL_PORTED_ENGINES` | Comma-separated local engine list |
|
| `LOCAL_PORTED_ENGINES` | Comma-separated local engine list |
|
||||||
| `HTTP_TIMEOUT` | Upstream request timeout |
|
| `HTTP_TIMEOUT` | Upstream request timeout |
|
||||||
| `BRAVE_API_KEY` | Brave Search API key |
|
| `BRAVE_API_KEY` | Brave Search API key |
|
||||||
|
|
@ -177,7 +177,7 @@ See `config.example.toml` for the full list including rate limiting and CORS var
|
||||||
| Reddit | Reddit JSON API | Discussions |
|
| Reddit | Reddit JSON API | Discussions |
|
||||||
| Bing | Bing RSS | General web |
|
| Bing | Bing RSS | General web |
|
||||||
|
|
||||||
Engines not listed in `engines.local_ported` are proxied to an upstream SearXNG instance if `upstream.url` is configured.
|
Engines not listed in `engines.local_ported` are proxied to an upstream metasearch instance if `upstream.url` is configured.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,10 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := search.NewService(search.ServiceConfig{
|
svc := search.NewService(search.ServiceConfig{
|
||||||
UpstreamURL: cfg.Upstream.URL,
|
UpstreamURL: cfg.Upstream.URL,
|
||||||
HTTPTimeout: cfg.HTTPTimeout(),
|
HTTPTimeout: cfg.HTTPTimeout(),
|
||||||
Cache: searchCache,
|
Cache: searchCache,
|
||||||
|
EnginesConfig: cfg,
|
||||||
})
|
})
|
||||||
|
|
||||||
acSvc := autocomplete.NewService(cfg.Upstream.URL, cfg.HTTPTimeout())
|
acSvc := autocomplete.NewService(cfg.Upstream.URL, cfg.HTTPTimeout())
|
||||||
|
|
@ -103,7 +104,7 @@ func main() {
|
||||||
}, logger)(handler)
|
}, logger)(handler)
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
||||||
logger.Info("searxng-go starting",
|
logger.Info("kafka starting",
|
||||||
"addr", addr,
|
"addr", addr,
|
||||||
"cache", searchCache.Enabled(),
|
"cache", searchCache.Enabled(),
|
||||||
"rate_limit", cfg.RateLimit.Requests > 0,
|
"rate_limit", cfg.RateLimit.Requests > 0,
|
||||||
|
|
@ -15,14 +15,14 @@ http_timeout = "10s"
|
||||||
base_url = ""
|
base_url = ""
|
||||||
|
|
||||||
[upstream]
|
[upstream]
|
||||||
# URL of an upstream SearXNG instance for unported engines (env: UPSTREAM_SEARXNG_URL)
|
# URL of an upstream metasearch instance for unported engines (env: UPSTREAM_SEARXNG_URL)
|
||||||
# Leave empty to run without an upstream proxy.
|
# Leave empty to run without an upstream proxy.
|
||||||
url = ""
|
url = ""
|
||||||
|
|
||||||
[engines]
|
[engines]
|
||||||
# Comma-separated list of engines to execute locally in Go (env: LOCAL_PORTED_ENGINES)
|
# Comma-separated list of engines to execute locally in Go (env: LOCAL_PORTED_ENGINES)
|
||||||
# Engines not listed here will be proxied to upstream SearXNG.
|
# Engines not listed here will be proxied to the upstream instance.
|
||||||
local_ported = ["wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing"]
|
local_ported = ["wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing", "google", "youtube"]
|
||||||
|
|
||||||
[engines.brave]
|
[engines.brave]
|
||||||
# Brave Search API key (env: BRAVE_API_KEY)
|
# Brave Search API key (env: BRAVE_API_KEY)
|
||||||
|
|
@ -35,6 +35,10 @@ access_token = ""
|
||||||
category = "web-lite"
|
category = "web-lite"
|
||||||
results_per_page = 10
|
results_per_page = 10
|
||||||
|
|
||||||
|
[engines.youtube]
|
||||||
|
# YouTube Data API v3 key (env: YOUTUBE_API_KEY)
|
||||||
|
api_key = ""
|
||||||
|
|
||||||
[cache]
|
[cache]
|
||||||
# Valkey/Redis cache for search results.
|
# Valkey/Redis cache for search results.
|
||||||
# Leave address empty to disable caching entirely.
|
# Leave address empty to disable caching entirely.
|
||||||
|
|
|
||||||
747
docs/superpowers/plans/2026-03-22-settings-ui.md
Normal file
747
docs/superpowers/plans/2026-03-22-settings-ui.md
Normal file
|
|
@ -0,0 +1,747 @@
|
||||||
|
# Settings UI Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** A preferences popover panel (top-right on desktop, bottom sheet on mobile) that lets users set theme, enabled engines, safe search, and default format. All changes auto-save to `localStorage` and apply immediately to the DOM.
|
||||||
|
|
||||||
|
**Architecture:** Pure client-side JS + CSS added alongside existing templates. No Go changes. Settings persist via `localStorage` key `kafka_prefs`. Theme applies via `data-theme` attribute on `<html>`.
|
||||||
|
|
||||||
|
**Tech Stack:** Vanilla JS (no framework), existing `kafka.css` custom properties, HTMX for search.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| Action | File |
|
||||||
|
|--------|------|
|
||||||
|
| Create | `internal/views/static/js/settings.js` |
|
||||||
|
| Modify | `internal/views/static/css/kafka.css` |
|
||||||
|
| Modify | `internal/views/templates/base.html` |
|
||||||
|
| Modify | `internal/views/templates/index.html` |
|
||||||
|
| Modify | `internal/views/templates/results.html` |
|
||||||
|
| Modify | `internal/views/views.go` |
|
||||||
|
|
||||||
|
**Key insight on engine preferences:** `ParseSearchRequest` reads `engines` as a CSV form value (`r.FormValue("engines")`). The search forms in `index.html` and `results.html` will get a hidden `#engines-input` field that is kept in sync with localStorage. On submit, the engines preference is sent as a normal form field. HTMX `hx-include="this"` already includes the form element, so the hidden input is automatically included in the request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: CSS — Popover, toggles, bottom sheet
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/views/static/css/kafka.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add CSS for popover, triggers, toggles, bottom sheet**
|
||||||
|
|
||||||
|
Append the following to `kafka.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ============================================
|
||||||
|
Settings Panel
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.site-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
background: var(--color-header-background);
|
||||||
|
border-bottom: 1px solid var(--color-header-border);
|
||||||
|
}
|
||||||
|
.site-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-base-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gear trigger button */
|
||||||
|
.settings-trigger {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--color-base-font);
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s, background 0.2s;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.settings-trigger:hover,
|
||||||
|
.settings-trigger[aria-expanded="true"] {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--color-sidebar-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Popover panel */
|
||||||
|
.settings-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
width: 280px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-base-background);
|
||||||
|
border: 1px solid var(--color-sidebar-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: 200;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.settings-popover[data-open="true"] {
|
||||||
|
display: flex;
|
||||||
|
animation: settings-slide-in 0.2s ease;
|
||||||
|
}
|
||||||
|
@keyframes settings-slide-in {
|
||||||
|
from { opacity: 0; transform: translateY(-8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-popover-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-sidebar-border);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.settings-popover-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-base-font);
|
||||||
|
opacity: 0.6;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.settings-popover-close:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.settings-popover-body {
|
||||||
|
padding: 0.8rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-title {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-suggestion);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme buttons */
|
||||||
|
.theme-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.theme-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border: 1px solid var(--color-sidebar-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-btn-background);
|
||||||
|
color: var(--color-base-font);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.theme-btn:hover { background: var(--color-btn-hover); }
|
||||||
|
.theme-btn.active {
|
||||||
|
background: var(--color-link);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--color-link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Engine toggles — 2-column grid */
|
||||||
|
.engine-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.engine-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-sidebar-background);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.engine-toggle input[type="checkbox"] {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--color-link);
|
||||||
|
}
|
||||||
|
.engine-toggle span {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search defaults */
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
.setting-row label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.setting-row select {
|
||||||
|
width: 110px;
|
||||||
|
padding: 0.3rem 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border: 1px solid var(--color-sidebar-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-base-background);
|
||||||
|
color: var(--color-base-font);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mid-search notice */
|
||||||
|
.settings-notice {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-suggestion);
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme via data-theme attribute */
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: Bottom sheet + FAB trigger */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
/* Hide desktop trigger, show FAB */
|
||||||
|
.settings-trigger-desktop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.settings-trigger-mobile {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.settings-popover {
|
||||||
|
position: fixed;
|
||||||
|
top: auto;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
border-radius: var(--radius) var(--radius) 0 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
/* FAB: fixed bottom-right button visible only on mobile */
|
||||||
|
.settings-trigger-mobile {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-link);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
z-index: 199;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The existing `:root` and `@media (prefers-color-scheme: dark)` blocks provide the "system" theme. `html[data-theme="dark"]` overrides only apply when the user explicitly picks dark mode. When `theme === 'system'`, the `data-theme` attribute is removed and the browser's `prefers-color-scheme` media query kicks in via the existing CSS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify existing tests still pass**
|
||||||
|
|
||||||
|
Run: `go test ./...`
|
||||||
|
Expected: all pass
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/views/static/css/kafka.css
|
||||||
|
git commit -m "feat(settings): add popover, toggle, and bottom-sheet CSS"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: JS — Settings logic
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/views/static/js/settings.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the settings JS module**
|
||||||
|
|
||||||
|
Create `internal/views/static/js/settings.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var ALL_ENGINES = [
|
||||||
|
'wikipedia', 'arxiv', 'crossref', 'braveapi',
|
||||||
|
'qwant', 'duckduckgo', 'github', 'reddit', 'bing'
|
||||||
|
];
|
||||||
|
|
||||||
|
var DEFAULT_PREFS = {
|
||||||
|
theme: 'system',
|
||||||
|
engines: ALL_ENGINES.slice(),
|
||||||
|
safeSearch: 'moderate',
|
||||||
|
format: 'html'
|
||||||
|
};
|
||||||
|
|
||||||
|
var STORAGE_KEY = 'kafka_prefs';
|
||||||
|
|
||||||
|
// ── Persistence ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function loadPrefs() {
|
||||||
|
try {
|
||||||
|
var raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return { theme: DEFAULT_PREFS.theme, engines: DEFAULT_PREFS.engines.slice(), safeSearch: DEFAULT_PREFS.safeSearch, format: DEFAULT_PREFS.format };
|
||||||
|
var saved = JSON.parse(raw);
|
||||||
|
return { theme: saved.theme || DEFAULT_PREFS.theme, engines: saved.engines || DEFAULT_PREFS.engines.slice(), safeSearch: saved.safeSearch || DEFAULT_PREFS.safeSearch, format: saved.format || DEFAULT_PREFS.format };
|
||||||
|
} catch (e) {
|
||||||
|
return { theme: DEFAULT_PREFS.theme, engines: DEFAULT_PREFS.engines.slice(), safeSearch: DEFAULT_PREFS.safeSearch, format: DEFAULT_PREFS.format };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePrefs(prefs) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ theme: prefs.theme, engines: prefs.engines, safeSearch: prefs.safeSearch, format: prefs.format }));
|
||||||
|
} catch (e) { /* quota or private mode */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Theme application ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
if (theme === 'system') {
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Engine input sync ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function syncEngineInput(prefs) {
|
||||||
|
var input = document.getElementById('engines-input');
|
||||||
|
if (input) input.value = prefs.engines.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Panel open / close ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function closePanel() {
|
||||||
|
var panel = document.getElementById('settings-popover');
|
||||||
|
var trigger = document.getElementById('settings-trigger');
|
||||||
|
if (!panel) return;
|
||||||
|
panel.setAttribute('data-open', 'false');
|
||||||
|
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
||||||
|
if (trigger) trigger.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPanel() {
|
||||||
|
var panel = document.getElementById('settings-popover');
|
||||||
|
var trigger = document.getElementById('settings-trigger');
|
||||||
|
if (!panel) return;
|
||||||
|
panel.setAttribute('data-open', 'true');
|
||||||
|
if (trigger) trigger.setAttribute('aria-expanded', 'true');
|
||||||
|
var focusable = panel.querySelector('button, input, select');
|
||||||
|
if (focusable) focusable.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Escape key ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
var panel = document.getElementById('settings-popover');
|
||||||
|
if (!panel || panel.getAttribute('data-open') !== 'true') return;
|
||||||
|
closePanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Click outside ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var panel = document.getElementById('settings-popover');
|
||||||
|
var trigger = document.getElementById('settings-trigger');
|
||||||
|
if (!panel || panel.getAttribute('data-open') !== 'true') return;
|
||||||
|
if (!panel.contains(e.target) && (!trigger || !trigger.contains(e.target))) {
|
||||||
|
closePanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Focus trap ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key !== 'Tab') return;
|
||||||
|
var panel = document.getElementById('settings-popover');
|
||||||
|
if (!panel || panel.getAttribute('data-open') !== 'true') return;
|
||||||
|
var focusable = Array.prototype.slice.call(panel.querySelectorAll('button, input, select, [tabindex]:not([tabindex="-1"])'));
|
||||||
|
if (!focusable.length) return;
|
||||||
|
var first = focusable[0];
|
||||||
|
var last = focusable[focusable.length - 1];
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel(prefs) {
|
||||||
|
var panel = document.getElementById('settings-popover');
|
||||||
|
if (!panel) return;
|
||||||
|
var body = panel.querySelector('.settings-popover-body');
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
var themeBtns = '';
|
||||||
|
['light', 'dark', 'system'].forEach(function(t) {
|
||||||
|
var icons = { light: '\u2600', dark: '\u263D', system: '\u2318' };
|
||||||
|
var labels = { light: 'Light', dark: 'Dark', system: 'System' };
|
||||||
|
var active = prefs.theme === t ? ' active' : '';
|
||||||
|
themeBtns += '<button class="theme-btn' + active + '" data-theme="' + t + '">' + icons[t] + ' ' + labels[t] + '</button>';
|
||||||
|
});
|
||||||
|
|
||||||
|
var engineToggles = '';
|
||||||
|
ALL_ENGINES.forEach(function(name) {
|
||||||
|
var checked = prefs.engines.indexOf(name) !== -1 ? ' checked' : '';
|
||||||
|
engineToggles += '<label class="engine-toggle"><input type="checkbox" value="' + escapeHtml(name) + '"' + checked + '><span>' + escapeHtml(name) + '</span></label>';
|
||||||
|
});
|
||||||
|
|
||||||
|
var ssOptions = [
|
||||||
|
{ val: 'moderate', label: 'Moderate' },
|
||||||
|
{ val: 'strict', label: 'Strict' },
|
||||||
|
{ val: 'off', label: 'Off' }
|
||||||
|
];
|
||||||
|
var fmtOptions = [
|
||||||
|
{ val: 'html', label: 'HTML' },
|
||||||
|
{ val: 'json', label: 'JSON' },
|
||||||
|
{ val: 'csv', label: 'CSV' },
|
||||||
|
{ val: 'rss', label: 'RSS' }
|
||||||
|
];
|
||||||
|
var ssOptionsHtml = '';
|
||||||
|
var fmtOptionsHtml = '';
|
||||||
|
ssOptions.forEach(function(o) {
|
||||||
|
var sel = prefs.safeSearch === o.val ? ' selected' : '';
|
||||||
|
ssOptionsHtml += '<option value="' + o.val + '"' + sel + '>' + o.label + '</option>';
|
||||||
|
});
|
||||||
|
fmtOptions.forEach(function(o) {
|
||||||
|
var sel = prefs.format === o.val ? ' selected' : '';
|
||||||
|
fmtOptionsHtml += '<option value="' + o.val + '"' + sel + '>' + o.label + '</option>';
|
||||||
|
});
|
||||||
|
|
||||||
|
body.innerHTML =
|
||||||
|
'<div class="settings-section">' +
|
||||||
|
'<div class="settings-section-title">Appearance</div>' +
|
||||||
|
'<div class="theme-buttons">' + themeBtns + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="settings-section">' +
|
||||||
|
'<div class="settings-section-title">Engines</div>' +
|
||||||
|
'<div class="engine-grid">' + engineToggles + '</div>' +
|
||||||
|
'<p class="settings-notice">Engine changes apply to your next search.</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="settings-section">' +
|
||||||
|
'<div class="settings-section-title">Search Defaults</div>' +
|
||||||
|
'<div class="setting-row">' +
|
||||||
|
'<label for="pref-safesearch">Safe search</label>' +
|
||||||
|
'<select id="pref-safesearch">' + ssOptionsHtml + '</select>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="setting-row">' +
|
||||||
|
'<label for="pref-format">Default format</label>' +
|
||||||
|
'<select id="pref-format">' + fmtOptionsHtml + '</select>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
// Theme buttons
|
||||||
|
var themeBtnEls = panel.querySelectorAll('.theme-btn');
|
||||||
|
for (var i = 0; i < themeBtnEls.length; i++) {
|
||||||
|
themeBtnEls[i].addEventListener('click', (function(btn) {
|
||||||
|
return function() {
|
||||||
|
prefs.theme = btn.getAttribute('data-theme');
|
||||||
|
savePrefs(prefs);
|
||||||
|
applyTheme(prefs.theme);
|
||||||
|
syncEngineInput(prefs);
|
||||||
|
renderPanel(prefs);
|
||||||
|
};
|
||||||
|
})(themeBtnEls[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engine checkboxes
|
||||||
|
var checkboxes = panel.querySelectorAll('.engine-toggle input[type="checkbox"]');
|
||||||
|
for (var j = 0; j < checkboxes.length; j++) {
|
||||||
|
checkboxes[j].addEventListener('change', (function(cb) {
|
||||||
|
return function() {
|
||||||
|
var checked = Array.prototype.slice.call(panel.querySelectorAll('.engine-toggle input[type="checkbox"]:checked')).map(function(el) { return el.value; });
|
||||||
|
if (checked.length === 0) { cb.checked = true; return; }
|
||||||
|
prefs.engines = checked;
|
||||||
|
savePrefs(prefs);
|
||||||
|
syncEngineInput(prefs);
|
||||||
|
};
|
||||||
|
})(checkboxes[j]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe search
|
||||||
|
var ssEl = panel.querySelector('#pref-safesearch');
|
||||||
|
if (ssEl) {
|
||||||
|
ssEl.addEventListener('change', function() {
|
||||||
|
prefs.safeSearch = ssEl.value;
|
||||||
|
savePrefs(prefs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format
|
||||||
|
var fmtEl = panel.querySelector('#pref-format');
|
||||||
|
if (fmtEl) {
|
||||||
|
fmtEl.addEventListener('change', function() {
|
||||||
|
prefs.format = fmtEl.value;
|
||||||
|
savePrefs(prefs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
var closeBtn = panel.querySelector('.settings-popover-close');
|
||||||
|
if (closeBtn) closeBtn.addEventListener('click', closePanel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function initSettings() {
|
||||||
|
var prefs = loadPrefs();
|
||||||
|
applyTheme(prefs.theme);
|
||||||
|
syncEngineInput(prefs);
|
||||||
|
|
||||||
|
var panel = document.getElementById('settings-popover');
|
||||||
|
var trigger = document.getElementById('settings-trigger');
|
||||||
|
var mobileTrigger = document.getElementById('settings-trigger-mobile');
|
||||||
|
|
||||||
|
if (panel) {
|
||||||
|
renderPanel(prefs);
|
||||||
|
|
||||||
|
function togglePanel() {
|
||||||
|
var isOpen = panel.getAttribute('data-open') === 'true';
|
||||||
|
if (isOpen) closePanel(); else openPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trigger) trigger.addEventListener('click', togglePanel);
|
||||||
|
if (mobileTrigger) mobileTrigger.addEventListener('click', togglePanel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initSettings);
|
||||||
|
} else {
|
||||||
|
initSettings();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify JS syntax**
|
||||||
|
|
||||||
|
Run: `node --check internal/views/static/js/settings.js`
|
||||||
|
Expected: no output (exit 0)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/views/static/js/settings.js
|
||||||
|
git commit -m "feat(settings): add JS module for localStorage preferences and panel"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: HTML — Gear trigger, panel markup, header in base
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/views/templates/base.html`
|
||||||
|
- Modify: `internal/views/views.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add ShowHeader to PageData**
|
||||||
|
|
||||||
|
In `views.go`, add `ShowHeader bool` to `PageData` struct.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Set ShowHeader in render functions**
|
||||||
|
|
||||||
|
In `RenderIndex` and `RenderSearch`, set `PageData.ShowHeader = true`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update base.html — add header and settings markup**
|
||||||
|
|
||||||
|
In `base.html`, update the `<body>` to:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<body class="{{if .Query}}search_on_results{{end}}">
|
||||||
|
{{if .ShowHeader}}
|
||||||
|
<header class="site-header">
|
||||||
|
<span class="site-title">kafka</span>
|
||||||
|
<!-- Desktop trigger (hidden on mobile) -->
|
||||||
|
<button id="settings-trigger" class="settings-trigger settings-trigger-desktop"
|
||||||
|
aria-label="Preferences" aria-expanded="false" aria-controls="settings-popover">⚙</button>
|
||||||
|
</header>
|
||||||
|
<!-- Mobile FAB trigger (hidden on desktop, shown via CSS on mobile) -->
|
||||||
|
<button id="settings-trigger-mobile" class="settings-trigger settings-trigger-mobile"
|
||||||
|
aria-label="Preferences" aria-expanded="false" aria-controls="settings-popover"
|
||||||
|
style="display:none;">⚙</button>
|
||||||
|
{{end}}
|
||||||
|
<main>
|
||||||
|
{{template "content" .}}
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<p>Powered by <a href="https://git.ashisgreat.xyz/penal-colony/kafka">kafka</a> — a privacy-respecting, open metasearch engine</p>
|
||||||
|
</footer>
|
||||||
|
<script src="/static/js/settings.js"></script>
|
||||||
|
<div id="settings-popover" data-open="false" role="dialog" aria-label="Preferences" aria-modal="true">
|
||||||
|
<div class="settings-popover-header">
|
||||||
|
Preferences
|
||||||
|
<button class="settings-popover-close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-popover-body"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var input = document.getElementById('q');
|
||||||
|
var dropdown = document.getElementById('autocomplete-dropdown');
|
||||||
|
var form = document.getElementById('search-form');
|
||||||
|
var debounceTimer = null;
|
||||||
|
var suggestions = [];
|
||||||
|
var activeIndex = -1;
|
||||||
|
var fetchController = null;
|
||||||
|
// ... existing autocomplete JS stays unchanged ...
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** The existing autocomplete `<script>` block is preserved as-is. Only the body wrapper and settings elements are added.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `go test ./...`
|
||||||
|
Expected: all pass
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/views/templates/base.html internal/views/views.go
|
||||||
|
git commit -m "feat(settings): add gear trigger and panel markup to base template"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Search form — Inject engine preferences
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/views/templates/index.html`
|
||||||
|
- Modify: `internal/views/templates/results.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add hidden engines input to both search forms**
|
||||||
|
|
||||||
|
In `index.html`, add inside the `<form>`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<input type="hidden" name="engines" id="engines-input" value="">
|
||||||
|
```
|
||||||
|
|
||||||
|
In `results.html`, add inside the `<form>`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<input type="hidden" name="engines" id="engines-input" value="">
|
||||||
|
```
|
||||||
|
|
||||||
|
The `value` is populated by `syncEngineInput(prefs)` on page load. When the form submits (regular GET or HTMX), the `engines` parameter is included as a CSV string, which `ParseSearchRequest` reads correctly via `r.FormValue("engines")`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify existing search works**
|
||||||
|
|
||||||
|
Run: `go run ./cmd/kafka -config config.toml`
|
||||||
|
Open: `http://localhost:8080`
|
||||||
|
Search for "golang" — results should appear as normal.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/views/templates/index.html internal/views/templates/results.html
|
||||||
|
git commit -m "feat(settings): add hidden engines input to search forms"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: End-to-end verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Start server**
|
||||||
|
|
||||||
|
Run: `go run ./cmd/kafka -config config.toml`
|
||||||
|
Open: `http://localhost:8080`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify gear icon and panel**
|
||||||
|
|
||||||
|
Click the gear icon in the header — panel drops down from top-right with Appearance, Engines, and Search Defaults sections.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify theme persistence**
|
||||||
|
|
||||||
|
Click Dark → page colors change immediately. Refresh → dark theme persists.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify engine toggle persistence**
|
||||||
|
|
||||||
|
Uncheck "wikipedia", refresh → "wikipedia" stays unchecked.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify engines appear in search query**
|
||||||
|
|
||||||
|
With wikipedia unchecked, open DevTools → Network tab, search "golang". Verify request URL includes `&engines=arxiv,crossref,...` (no wikipedia).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify mobile bottom sheet**
|
||||||
|
|
||||||
|
Resize to <768px or use mobile device emulation. Click gear → full-width sheet slides up from bottom.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Final commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat: complete settings UI — popover, auto-save, theme, engines, mobile bottom-sheet"
|
||||||
|
```
|
||||||
80
docs/superpowers/specs/2026-03-22-settings-ui-design.md
Normal file
80
docs/superpowers/specs/2026-03-22-settings-ui-design.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# Settings UI Design — kafka
|
||||||
|
|
||||||
|
**Date:** 2026-03-22
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A lightweight preferences popover anchored to the top-right, just below the header. Triggered by a gear icon, it lets users adjust theme, enabled engines, and search defaults without leaving their current page. All changes auto-save to `localStorage` on every interaction.
|
||||||
|
|
||||||
|
## Layout & Structure
|
||||||
|
|
||||||
|
- **Trigger**: Gear icon (⚙️) in the top-right header, aligned with the header's right edge
|
||||||
|
- **Panel**: 280px wide, max-height 420px, scrollable internally, rounded corners, subtle shadow, anchored top-right (drops down from trigger, like a dropdown)
|
||||||
|
- **Close**: × button in panel header, click outside the panel, or pressing Escape
|
||||||
|
- **No Save button** — every interaction immediately writes to `localStorage`
|
||||||
|
|
||||||
|
## Interaction Flow
|
||||||
|
|
||||||
|
1. User clicks ⚙️ → panel drops down from top-right (200ms ease)
|
||||||
|
2. User toggles/clicks → changes apply instantly to DOM + write to `localStorage`
|
||||||
|
3. User clicks × or outside or Escape → panel closes, settings persist
|
||||||
|
4. **Accessibility**: Focus is trapped within the panel while open. Trigger button uses `aria-expanded` and `aria-controls`. Escape key closes the panel.
|
||||||
|
|
||||||
|
## Mid-Search Changes
|
||||||
|
|
||||||
|
When opened during an active search on `results.html`:
|
||||||
|
- Engine toggles update `localStorage` immediately, but **current results remain unchanged**
|
||||||
|
- A subtle inline note below the engines section: *"Engine changes apply to your next search"*
|
||||||
|
|
||||||
|
## Sections
|
||||||
|
|
||||||
|
### Appearance
|
||||||
|
|
||||||
|
- Three theme buttons: ☀️ Light / 🌙 Dark / 💻 System
|
||||||
|
- Clicking immediately applies via `document.body.classList` + writes to localStorage
|
||||||
|
- "System" reads `prefers-color-scheme` and updates on change
|
||||||
|
|
||||||
|
### Engines
|
||||||
|
|
||||||
|
- 2-column grid of toggle switches for all 9 engines
|
||||||
|
- Each row: engine name + toggle switch
|
||||||
|
- Enabled = filled accent color; Disabled = gray outline
|
||||||
|
|
||||||
|
### Search Defaults
|
||||||
|
|
||||||
|
- Safe search: dropdown (Moderate / Strict / Off)
|
||||||
|
- Default format: dropdown (HTML / JSON / CSV)
|
||||||
|
|
||||||
|
## Default State
|
||||||
|
|
||||||
|
```js
|
||||||
|
const DEFAULT_PREFS = {
|
||||||
|
theme: "system",
|
||||||
|
engines: ["wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing"],
|
||||||
|
safeSearch: "moderate",
|
||||||
|
format: "html"
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Written on every interaction
|
||||||
|
localStorage.setItem('kafka_prefs', JSON.stringify({ ... }));
|
||||||
|
|
||||||
|
// Read on page load — merge with DEFAULT_PREFS
|
||||||
|
const saved = JSON.parse(localStorage.getItem('kafka_prefs') || '{}');
|
||||||
|
const prefs = { ...DEFAULT_PREFS, ...saved };
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responsive Behavior
|
||||||
|
|
||||||
|
- **Mobile (<768px)**: Panel becomes a **bottom sheet** — 100% width, slides up from the bottom, top corners rounded, max-height 70vh. Trigger moves to a fixed bottom-right FAB button.
|
||||||
|
- Panel never covers the search input
|
||||||
|
|
||||||
|
## Applied to Existing Code
|
||||||
|
|
||||||
|
- `base.html` — add gear button in header, panel markup at end of `<body>`
|
||||||
|
- `kafka.css` — popover styles, toggle switch styles, bottom sheet styles for mobile
|
||||||
|
- `settings.js` — localStorage read/write, theme application, panel toggle, aria attributes, focus trap
|
||||||
6
go.mod
6
go.mod
|
|
@ -1,10 +1,10 @@
|
||||||
module github.com/metamorphosis-dev/kafka
|
module github.com/metamorphosis-dev/kafka
|
||||||
|
|
||||||
go 1.25.0
|
go 1.24
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.5.0
|
github.com/BurntSushi/toml v1.5.0
|
||||||
github.com/PuerkitoBio/goquery v1.12.0
|
github.com/PuerkitoBio/goquery v1.9.0
|
||||||
github.com/redis/go-redis/v9 v9.18.0
|
github.com/redis/go-redis/v9 v9.18.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -15,3 +15,5 @@ require (
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
golang.org/x/net v0.52.0 // indirect
|
golang.org/x/net v0.52.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
replace golang.org/x/net v0.52.0 => golang.org/x/net v0.33.0
|
||||||
|
|
|
||||||
6
go.sum
6
go.sum
|
|
@ -1,9 +1,11 @@
|
||||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
github.com/PuerkitoBio/goquery v1.9.0 h1:zgjKkdpRY9T97Q5DCtcXwfqkcylSFIVCocZmn2huTp8=
|
||||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
github.com/PuerkitoBio/goquery v1.9.0/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
|
||||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||||
|
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||||
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service fetches search suggestions from an upstream SearXNG instance
|
// Service fetches search suggestions from an upstream metasearch instance
|
||||||
// or falls back to Wikipedia's OpenSearch API.
|
// or falls back to Wikipedia's OpenSearch API.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
upstreamURL string
|
upstreamURL string
|
||||||
|
|
@ -40,7 +40,7 @@ func (s *Service) Suggestions(ctx context.Context, query string) ([]string, erro
|
||||||
return s.wikipediaSuggestions(ctx, query)
|
return s.wikipediaSuggestions(ctx, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
// upstreamSuggestions proxies to an upstream SearXNG /autocompleter endpoint.
|
// upstreamSuggestions proxies to an upstream /autocompleter endpoint.
|
||||||
func (s *Service) upstreamSuggestions(ctx context.Context, query string) ([]string, error) {
|
func (s *Service) upstreamSuggestions(ctx context.Context, query string) ([]string, error) {
|
||||||
u := s.upstreamURL + "/autocompleter?" + url.Values{"q": {query}}.Encode()
|
u := s.upstreamURL + "/autocompleter?" + url.Values{"q": {query}}.Encode()
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
|
@ -64,7 +64,7 @@ func (s *Service) upstreamSuggestions(ctx context.Context, query string) ([]stri
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearXNG /autocompleter returns a plain JSON array of strings.
|
// The /autocompleter endpoint returns a plain JSON array of strings.
|
||||||
var out []string
|
var out []string
|
||||||
if err := json.Unmarshal(body, &out); err != nil {
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ type EnginesConfig struct {
|
||||||
LocalPorted []string `toml:"local_ported"`
|
LocalPorted []string `toml:"local_ported"`
|
||||||
Brave BraveConfig `toml:"brave"`
|
Brave BraveConfig `toml:"brave"`
|
||||||
Qwant QwantConfig `toml:"qwant"`
|
Qwant QwantConfig `toml:"qwant"`
|
||||||
|
YouTube YouTubeConfig `toml:"youtube"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CacheConfig holds Valkey/Redis cache settings.
|
// CacheConfig holds Valkey/Redis cache settings.
|
||||||
|
|
@ -85,6 +86,10 @@ type QwantConfig struct {
|
||||||
ResultsPerPage int `toml:"results_per_page"`
|
ResultsPerPage int `toml:"results_per_page"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type YouTubeConfig struct {
|
||||||
|
APIKey string `toml:"api_key"`
|
||||||
|
}
|
||||||
|
|
||||||
// Load reads configuration from the given TOML file path.
|
// Load reads configuration from the given TOML file path.
|
||||||
// If the file does not exist, it returns defaults (empty values where applicable).
|
// If the file does not exist, it returns defaults (empty values where applicable).
|
||||||
// Environment variables are used as fallbacks for any zero-value fields.
|
// Environment variables are used as fallbacks for any zero-value fields.
|
||||||
|
|
@ -109,7 +114,7 @@ func defaultConfig() *Config {
|
||||||
},
|
},
|
||||||
Upstream: UpstreamConfig{},
|
Upstream: UpstreamConfig{},
|
||||||
Engines: EnginesConfig{
|
Engines: EnginesConfig{
|
||||||
LocalPorted: []string{"wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing"},
|
LocalPorted: []string{"wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing", "google", "youtube"},
|
||||||
Qwant: QwantConfig{
|
Qwant: QwantConfig{
|
||||||
Category: "web-lite",
|
Category: "web-lite",
|
||||||
ResultsPerPage: 10,
|
ResultsPerPage: 10,
|
||||||
|
|
@ -151,6 +156,9 @@ func applyEnvOverrides(cfg *Config) {
|
||||||
if v := os.Getenv("BRAVE_ACCESS_TOKEN"); v != "" {
|
if v := os.Getenv("BRAVE_ACCESS_TOKEN"); v != "" {
|
||||||
cfg.Engines.Brave.AccessToken = v
|
cfg.Engines.Brave.AccessToken = v
|
||||||
}
|
}
|
||||||
|
if v := os.Getenv("YOUTUBE_API_KEY"); v != "" {
|
||||||
|
cfg.Engines.YouTube.APIKey = v
|
||||||
|
}
|
||||||
if v := os.Getenv("VALKEY_ADDRESS"); v != "" {
|
if v := os.Getenv("VALKEY_ADDRESS"); v != "" {
|
||||||
cfg.Cache.Address = v
|
cfg.Cache.Address = v
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ func TestLoadDefaults(t *testing.T) {
|
||||||
if cfg.Server.Port != 8080 {
|
if cfg.Server.Port != 8080 {
|
||||||
t.Errorf("expected default port 8080, got %d", cfg.Server.Port)
|
t.Errorf("expected default port 8080, got %d", cfg.Server.Port)
|
||||||
}
|
}
|
||||||
if len(cfg.Engines.LocalPorted) != 9 {
|
if len(cfg.Engines.LocalPorted) != 11 {
|
||||||
t.Errorf("expected 9 default engines, got %d", len(cfg.Engines.LocalPorted))
|
t.Errorf("expected 11 default engines, got %d", len(cfg.Engines.LocalPorted))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,35 +5,36 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MainResult represents one element of SearXNG's `results` array.
|
// MainResult represents one element of the `results` array.
|
||||||
//
|
//
|
||||||
// SearXNG returns many additional keys beyond what templates use. To keep the
|
// The API returns many additional keys beyond what templates use. To keep the
|
||||||
// contract stable for proxying/merging, we preserve all unknown keys in
|
// contract stable for proxying/merging, we preserve all unknown keys in
|
||||||
// `raw` and re-emit them via MarshalJSON.
|
// `raw` and re-emit them via MarshalJSON.
|
||||||
type MainResult struct {
|
type MainResult struct {
|
||||||
raw map[string]any
|
raw map[string]any
|
||||||
|
|
||||||
// Common fields used by SearXNG templates (RSS uses: title, url, content, pubdate).
|
// Common fields used by templates (RSS uses: title, url, content, pubdate).
|
||||||
Template string `json:"template"`
|
Template string `json:"template"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
URL *string `json:"url"`
|
URL *string `json:"url"`
|
||||||
Pubdate *string `json:"pubdate"`
|
Pubdate *string `json:"pubdate"`
|
||||||
|
Thumbnail string `json:"thumbnail"`
|
||||||
|
|
||||||
Engine string `json:"engine"`
|
Engine string `json:"engine"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Priority string `json:"priority"`
|
Priority string `json:"priority"`
|
||||||
|
|
||||||
Positions []int `json:"positions"`
|
Positions []int `json:"positions"`
|
||||||
Engines []string `json:"engines"`
|
Engines []string `json:"engines"`
|
||||||
|
|
||||||
// These fields exist in SearXNG's MainResult base; keep them so downstream
|
// These fields exist in the MainResult base; keep them so downstream
|
||||||
// callers can generate richer output later.
|
// callers can generate richer output later.
|
||||||
OpenGroup bool `json:"open_group"`
|
OpenGroup bool `json:"open_group"`
|
||||||
CloseGroup bool `json:"close_group"`
|
CloseGroup bool `json:"close_group"`
|
||||||
|
|
||||||
// parsed_url in SearXNG is emitted as a tuple; we preserve it as-is.
|
// parsed_url is emitted as a tuple; we preserve it as-is.
|
||||||
ParsedURL any `json:"parsed_url"`
|
ParsedURL any `json:"parsed_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,6 +55,7 @@ func (mr *MainResult) UnmarshalJSON(data []byte) error {
|
||||||
mr.Title = stringOrEmpty(m["title"])
|
mr.Title = stringOrEmpty(m["title"])
|
||||||
mr.Content = stringOrEmpty(m["content"])
|
mr.Content = stringOrEmpty(m["content"])
|
||||||
mr.Engine = stringOrEmpty(m["engine"])
|
mr.Engine = stringOrEmpty(m["engine"])
|
||||||
|
mr.Thumbnail = stringOrEmpty(m["thumbnail"])
|
||||||
mr.Category = stringOrEmpty(m["category"])
|
mr.Category = stringOrEmpty(m["category"])
|
||||||
mr.Priority = stringOrEmpty(m["priority"])
|
mr.Priority = stringOrEmpty(m["priority"])
|
||||||
|
|
||||||
|
|
@ -93,20 +95,21 @@ func (mr MainResult) MarshalJSON() ([]byte, error) {
|
||||||
|
|
||||||
// Otherwise, marshal the known fields.
|
// Otherwise, marshal the known fields.
|
||||||
m := map[string]any{
|
m := map[string]any{
|
||||||
"template": mr.Template,
|
"template": mr.Template,
|
||||||
"title": mr.Title,
|
"title": mr.Title,
|
||||||
"content": mr.Content,
|
"content": mr.Content,
|
||||||
"url": mr.URL,
|
"url": mr.URL,
|
||||||
"pubdate": mr.Pubdate,
|
"pubdate": mr.Pubdate,
|
||||||
"engine": mr.Engine,
|
"thumbnail": mr.Thumbnail,
|
||||||
"score": mr.Score,
|
"engine": mr.Engine,
|
||||||
"category": mr.Category,
|
"score": mr.Score,
|
||||||
"priority": mr.Priority,
|
"category": mr.Category,
|
||||||
|
"priority": mr.Priority,
|
||||||
"positions": mr.Positions,
|
"positions": mr.Positions,
|
||||||
"engines": mr.Engines,
|
"engines": mr.Engines,
|
||||||
"open_group": mr.OpenGroup,
|
"open_group": mr.OpenGroup,
|
||||||
"close_group": mr.CloseGroup,
|
"close_group": mr.CloseGroup,
|
||||||
"parsed_url": mr.ParsedURL,
|
"parsed_url": mr.ParsedURL,
|
||||||
}
|
}
|
||||||
return json.Marshal(m)
|
return json.Marshal(m)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
package contracts
|
package contracts
|
||||||
|
|
||||||
// OutputFormat matches SearXNG's `/search?format=...` values.
|
// OutputFormat matches the `/search?format=...` values.
|
||||||
type OutputFormat string
|
type OutputFormat string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -28,7 +28,7 @@ type SearchRequest struct {
|
||||||
Engines []string
|
Engines []string
|
||||||
Categories []string
|
Categories []string
|
||||||
|
|
||||||
// EngineData matches SearXNG's `engine_data-<engine>-<key>=<value>` parameters.
|
// EngineData matches the `engine_data-<engine>-<key>=<value>` parameters.
|
||||||
EngineData map[string]map[string]string
|
EngineData map[string]map[string]string
|
||||||
|
|
||||||
// AccessToken is an optional request token used to gate paid/limited engines.
|
// AccessToken is an optional request token used to gate paid/limited engines.
|
||||||
|
|
@ -36,7 +36,7 @@ type SearchRequest struct {
|
||||||
AccessToken string
|
AccessToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchResponse matches the JSON schema returned by SearXNG's `webutils.get_json_response()`.
|
// SearchResponse matches the JSON schema used by `webutils.get_json_response()`.
|
||||||
type SearchResponse struct {
|
type SearchResponse struct {
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
NumberOfResults int `json:"number_of_results"`
|
NumberOfResults int `json:"number_of_results"`
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import (
|
||||||
"github.com/metamorphosis-dev/kafka/internal/contracts"
|
"github.com/metamorphosis-dev/kafka/internal/contracts"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BraveEngine implements the SearXNG `braveapi` engine (Brave Web Search API).
|
// BraveEngine implements the `braveapi` engine (Brave Web Search API).
|
||||||
//
|
//
|
||||||
// Config / gating:
|
// Config / gating:
|
||||||
// - BRAVE_API_KEY: required to call Brave
|
// - BRAVE_API_KEY: required to call Brave
|
||||||
|
|
@ -35,8 +35,8 @@ func (e *BraveEngine) Search(ctx context.Context, req contracts.SearchRequest) (
|
||||||
return contracts.SearchResponse{}, errors.New("brave engine not initialized")
|
return contracts.SearchResponse{}, errors.New("brave engine not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gate / config checks should not be treated as fatal errors; SearXNG
|
// Gate / config checks should not be treated as fatal errors; the reference
|
||||||
// treats misconfigured engines as unresponsive.
|
// implementation treats misconfigured engines as unresponsive.
|
||||||
if strings.TrimSpace(e.apiKey) == "" {
|
if strings.TrimSpace(e.apiKey) == "" {
|
||||||
return contracts.SearchResponse{
|
return contracts.SearchResponse{
|
||||||
Query: req.Query,
|
Query: req.Query,
|
||||||
|
|
@ -93,7 +93,7 @@ func (e *BraveEngine) Search(ctx context.Context, req contracts.SearchRequest) (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearXNG's python checks `if params["safesearch"]:` which treats any
|
// The reference implementation checks `if params["safesearch"]:` which treats any
|
||||||
// non-zero (moderate/strict) as strict.
|
// non-zero (moderate/strict) as strict.
|
||||||
if req.Safesearch > 0 {
|
if req.Safesearch > 0 {
|
||||||
args.Set("safesearch", "strict")
|
args.Set("safesearch", "strict")
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"github.com/metamorphosis-dev/kafka/internal/contracts"
|
"github.com/metamorphosis-dev/kafka/internal/contracts"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Engine is a Go-native implementation of a SearXNG engine.
|
// Engine is a Go-native implementation of a search engine.
|
||||||
//
|
//
|
||||||
// Implementations should return a SearchResponse containing only the results
|
// Implementations should return a SearchResponse containing only the results
|
||||||
// for that engine subset; the caller will merge multiple engine responses.
|
// for that engine subset; the caller will merge multiple engine responses.
|
||||||
|
|
|
||||||
|
|
@ -4,23 +4,42 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/metamorphosis-dev/kafka/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewDefaultPortedEngines returns the starter set of Go-native engines.
|
// NewDefaultPortedEngines returns the starter set of Go-native engines.
|
||||||
// The service can swap/extend this registry later as more engines are ported.
|
// The service can swap/extend this registry later as more engines are ported.
|
||||||
func NewDefaultPortedEngines(client *http.Client) map[string]Engine {
|
// If cfg is nil, falls back to reading API keys from environment variables.
|
||||||
|
func NewDefaultPortedEngines(client *http.Client, cfg *config.Config) map[string]Engine {
|
||||||
if client == nil {
|
if client == nil {
|
||||||
client = &http.Client{Timeout: 10 * time.Second}
|
client = &http.Client{Timeout: 10 * time.Second}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var braveAPIKey, braveAccessToken, youtubeAPIKey string
|
||||||
|
if cfg != nil {
|
||||||
|
braveAPIKey = cfg.Engines.Brave.APIKey
|
||||||
|
braveAccessToken = cfg.Engines.Brave.AccessToken
|
||||||
|
youtubeAPIKey = cfg.Engines.YouTube.APIKey
|
||||||
|
}
|
||||||
|
if braveAPIKey == "" {
|
||||||
|
braveAPIKey = os.Getenv("BRAVE_API_KEY")
|
||||||
|
}
|
||||||
|
if braveAccessToken == "" {
|
||||||
|
braveAccessToken = os.Getenv("BRAVE_ACCESS_TOKEN")
|
||||||
|
}
|
||||||
|
if youtubeAPIKey == "" {
|
||||||
|
youtubeAPIKey = os.Getenv("YOUTUBE_API_KEY")
|
||||||
|
}
|
||||||
|
|
||||||
return map[string]Engine{
|
return map[string]Engine{
|
||||||
"wikipedia": &WikipediaEngine{client: client},
|
"wikipedia": &WikipediaEngine{client: client},
|
||||||
"arxiv": &ArxivEngine{client: client},
|
"arxiv": &ArxivEngine{client: client},
|
||||||
"crossref": &CrossrefEngine{client: client},
|
"crossref": &CrossrefEngine{client: client},
|
||||||
"braveapi": &BraveEngine{
|
"braveapi": &BraveEngine{
|
||||||
client: client,
|
client: client,
|
||||||
apiKey: os.Getenv("BRAVE_API_KEY"),
|
apiKey: braveAPIKey,
|
||||||
accessGateToken: os.Getenv("BRAVE_ACCESS_TOKEN"),
|
accessGateToken: braveAccessToken,
|
||||||
resultsPerPage: 20,
|
resultsPerPage: 20,
|
||||||
},
|
},
|
||||||
"qwant": &QwantEngine{
|
"qwant": &QwantEngine{
|
||||||
|
|
@ -31,6 +50,12 @@ func NewDefaultPortedEngines(client *http.Client) map[string]Engine {
|
||||||
"duckduckgo": &DuckDuckGoEngine{client: client},
|
"duckduckgo": &DuckDuckGoEngine{client: client},
|
||||||
"github": &GitHubEngine{client: client},
|
"github": &GitHubEngine{client: client},
|
||||||
"reddit": &RedditEngine{client: client},
|
"reddit": &RedditEngine{client: client},
|
||||||
"bing": &BingEngine{client: client},
|
"bing": &BingEngine{client: client},
|
||||||
|
"google": &GoogleEngine{client: client},
|
||||||
|
"youtube": &YouTubeEngine{
|
||||||
|
client: client,
|
||||||
|
apiKey: youtubeAPIKey,
|
||||||
|
baseURL: "https://www.googleapis.com",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
271
internal/engines/google.go
Normal file
271
internal/engines/google.go
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
package engines
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/metamorphosis-dev/kafka/internal/contracts"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GSA User-Agent pool — these are Google Search Appliance identifiers
|
||||||
|
// that Google trusts for enterprise search appliance traffic.
|
||||||
|
var gsaUserAgents = []string{
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/399.2.845414227 Mobile/15E148 Safari/604.1",
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/406.0.862495628 Mobile/15E148 Safari/604.1",
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/406.0.862495628 Mobile/15E148 Safari/604.1",
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/406.0.862495628 Mobile/15E148 Safari/604.1",
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/399.2.845414227 Mobile/15E148 Safari/604.1",
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/406.0.862495628 Mobile/15E148 Safari/604.1",
|
||||||
|
}
|
||||||
|
|
||||||
|
func gsaUA() string {
|
||||||
|
return gsaUserAgents[0] // deterministic for now; could rotate
|
||||||
|
}
|
||||||
|
|
||||||
|
type GoogleEngine struct {
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GoogleEngine) Name() string { return "google" }
|
||||||
|
|
||||||
|
func (e *GoogleEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) {
|
||||||
|
if strings.TrimSpace(req.Query) == "" {
|
||||||
|
return contracts.SearchResponse{Query: req.Query}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
start := (req.Pageno - 1) * 10
|
||||||
|
query := url.QueryEscape(req.Query)
|
||||||
|
|
||||||
|
// Build URL like SearXNG does.
|
||||||
|
u := fmt.Sprintf(
|
||||||
|
"https://www.google.com/search?q=%s&filter=0&start=%d&hl=%s&lr=%s&safe=%s",
|
||||||
|
query,
|
||||||
|
start,
|
||||||
|
googleHL(req.Language),
|
||||||
|
googleUILanguage(req.Language),
|
||||||
|
googleSafeSearchLevel(req.Safesearch),
|
||||||
|
)
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.SearchResponse{}, err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("User-Agent", gsaUA())
|
||||||
|
httpReq.Header.Set("Accept", "*/*")
|
||||||
|
httpReq.AddCookie(&http.Cookie{Name: "CONSENT", Value: "YES+"})
|
||||||
|
|
||||||
|
resp, err := e.client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.SearchResponse{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Check for Google block / CAPTCHA page.
|
||||||
|
if detectGoogleSorry(resp) {
|
||||||
|
return contracts.SearchResponse{
|
||||||
|
Query: req.Query,
|
||||||
|
NumberOfResults: 0,
|
||||||
|
Results: nil,
|
||||||
|
Answers: []map[string]any{},
|
||||||
|
Corrections: []string{},
|
||||||
|
Infoboxes: []map[string]any{},
|
||||||
|
Suggestions: []string{},
|
||||||
|
UnresponsiveEngines: [][2]string{{"google", "blocked by Google (CAPTCHA/sorry page)"}},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
return contracts.SearchResponse{}, fmt.Errorf("google error: status=%d body=%q", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
|
||||||
|
if err != nil {
|
||||||
|
return contracts.SearchResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := parseGoogleResults(string(body), req.Query)
|
||||||
|
return contracts.SearchResponse{
|
||||||
|
Query: req.Query,
|
||||||
|
NumberOfResults: len(results),
|
||||||
|
Results: results,
|
||||||
|
Answers: []map[string]any{},
|
||||||
|
Corrections: []string{},
|
||||||
|
Infoboxes: []map[string]any{},
|
||||||
|
Suggestions: extractGoogleSuggestions(string(body)),
|
||||||
|
UnresponsiveEngines: [][2]string{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectGoogleSorry returns true if the response is a Google block/CAPTCHA page.
|
||||||
|
func detectGoogleSorry(resp *http.Response) bool {
|
||||||
|
if resp.Request != nil {
|
||||||
|
if resp.Request.URL.Host == "sorry.google.com" || strings.HasPrefix(resp.Request.URL.Path, "/sorry") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGoogleResults extracts search results from Google's HTML.
|
||||||
|
// Uses the same selectors as SearXNG: div.MjjYud for result containers.
|
||||||
|
func parseGoogleResults(body, query string) []contracts.MainResult {
|
||||||
|
var results []contracts.MainResult
|
||||||
|
|
||||||
|
// SearXNG selector: .//div[contains(@class, "MjjYud")]
|
||||||
|
// Each result block contains a title link and snippet.
|
||||||
|
// We simulate the XPath matching with regex-based extraction.
|
||||||
|
|
||||||
|
// Find all MjjYud div blocks.
|
||||||
|
mjjPattern := regexp.MustCompile(`<div[^>]*class="[^"]*MjjYud[^"]*"[^>]*>(.*?)</div>\s*(?=<div[^>]*class="[^"]*MjjYud|$)`)
|
||||||
|
matches := mjjPattern.FindAllStringSubmatch(body, -1)
|
||||||
|
|
||||||
|
for i, match := range matches {
|
||||||
|
if len(match) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
block := match[1]
|
||||||
|
|
||||||
|
// Extract title and URL from the result link.
|
||||||
|
// Pattern: <a href="/url?q=ACTUAL_URL&sa=..." ...>TITLE</a>
|
||||||
|
urlPattern := regexp.MustCompile(`<a[^>]+href="(/url\?q=[^"&]+)`)
|
||||||
|
urlMatch := urlPattern.FindStringSubmatch(block)
|
||||||
|
if len(urlMatch) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rawURL := urlMatch[1]
|
||||||
|
// Remove /url?q= prefix and decode.
|
||||||
|
actualURL := strings.TrimPrefix(rawURL, "/url?q=")
|
||||||
|
if amp := strings.Index(actualURL, "&"); amp != -1 {
|
||||||
|
actualURL = actualURL[:amp]
|
||||||
|
}
|
||||||
|
if decoded, err := url.QueryUnescape(actualURL); err == nil {
|
||||||
|
actualURL = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
if actualURL == "" || !strings.HasPrefix(actualURL, "http") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract title from the title tag.
|
||||||
|
titlePattern := regexp.MustCompile(`<span[^>]*class="[^"]*qrStP[^"]*"[^>]*>([^<]+)</span>`)
|
||||||
|
titleMatch := titlePattern.FindStringSubmatch(block)
|
||||||
|
title := query
|
||||||
|
if len(titleMatch) >= 2 {
|
||||||
|
title = stripTags(titleMatch[1])
|
||||||
|
} else {
|
||||||
|
// Fallback: extract visible text from an <a> with data-title or role="link"
|
||||||
|
linkTitlePattern := regexp.MustCompile(`<a[^>]+role="link"[^>]*>([^<]+)<`)
|
||||||
|
ltMatch := linkTitlePattern.FindStringSubmatch(block)
|
||||||
|
if len(ltMatch) >= 2 {
|
||||||
|
title = stripTags(ltMatch[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract snippet from data-sncf divs (SearXNG's approach).
|
||||||
|
snippet := extractGoogleSnippet(block)
|
||||||
|
|
||||||
|
urlPtr := actualURL
|
||||||
|
results = append(results, contracts.MainResult{
|
||||||
|
Title: title,
|
||||||
|
URL: &urlPtr,
|
||||||
|
Content: snippet,
|
||||||
|
Engine: "google",
|
||||||
|
Score: float64(len(matches) - i),
|
||||||
|
Category: "general",
|
||||||
|
Engines: []string{"google"},
|
||||||
|
Template: "default.html",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractGoogleSnippet extracts the snippet text from a Google result block.
|
||||||
|
func extractGoogleSnippet(block string) string {
|
||||||
|
// Google's snippets live in divs with data-sncf attribute.
|
||||||
|
// SearXNG looks for: .//div[contains(@data-sncf, "1")]
|
||||||
|
snippetPattern := regexp.MustCompile(`<div[^>]+data-sncf="1"[^>]*>(.*?)</div>`)
|
||||||
|
matches := snippetPattern.FindAllStringSubmatch(block, -1)
|
||||||
|
var parts []string
|
||||||
|
for _, m := range matches {
|
||||||
|
if len(m) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := stripTags(m[1])
|
||||||
|
if text != "" {
|
||||||
|
parts = append(parts, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractGoogleSuggestions extracts search suggestions from Google result cards.
|
||||||
|
func extractGoogleSuggestions(body string) []string {
|
||||||
|
var suggestions []string
|
||||||
|
// SearXNG xpath: //div[contains(@class, "ouy7Mc")]//a
|
||||||
|
suggestionPattern := regexp.MustCompile(`(?s)<div[^>]*class="[^"]*ouy7Mc[^"]*"[^>]*>.*?<a[^>]*>([^<]+)</a>`)
|
||||||
|
matches := suggestionPattern.FindAllStringSubmatch(body, -1)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range matches {
|
||||||
|
if len(m) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(stripTags(m[1]))
|
||||||
|
if s != "" && !seen[s] {
|
||||||
|
seen[s] = true
|
||||||
|
suggestions = append(suggestions, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return suggestions
|
||||||
|
}
|
||||||
|
|
||||||
|
// googleHL maps SearXNG locale to Google hl (host language) parameter.
|
||||||
|
// e.g. "en-US" -> "en-US"
|
||||||
|
func googleHL(lang string) string {
|
||||||
|
lang = strings.ToLower(strings.TrimSpace(lang))
|
||||||
|
if lang == "" || lang == "auto" {
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
return lang
|
||||||
|
}
|
||||||
|
|
||||||
|
// googleUILanguage maps SearXNG language to Google lr (language restrict) parameter.
|
||||||
|
// e.g. "en" -> "lang_en", "de" -> "lang_de"
|
||||||
|
func googleUILanguage(lang string) string {
|
||||||
|
lang = strings.ToLower(strings.Split(lang, "-")[0])
|
||||||
|
if lang == "" || lang == "auto" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "lang_" + lang
|
||||||
|
}
|
||||||
|
|
||||||
|
// googleSafeSearchLevel maps safesearch (0-2) to Google's safe parameter.
|
||||||
|
func googleSafeSearchLevel(safesearch int) string {
|
||||||
|
switch safesearch {
|
||||||
|
case 0:
|
||||||
|
return "off"
|
||||||
|
case 1:
|
||||||
|
return "medium"
|
||||||
|
case 2:
|
||||||
|
return "high"
|
||||||
|
default:
|
||||||
|
return "medium"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripTags removes HTML tags from a string.
|
||||||
|
func stripTags(s string) string {
|
||||||
|
stripper := regexp.MustCompile(`<[^>]*>`)
|
||||||
|
s = stripper.ReplaceAllString(s, "")
|
||||||
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
|
s = strings.ReplaceAll(s, """, `"`)
|
||||||
|
s = strings.ReplaceAll(s, "'", "'")
|
||||||
|
s = strings.ReplaceAll(s, " ", " ")
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ import (
|
||||||
"github.com/metamorphosis-dev/kafka/internal/contracts"
|
"github.com/metamorphosis-dev/kafka/internal/contracts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var defaultPortedEngines = []string{"wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing"}
|
var defaultPortedEngines = []string{"wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing", "google", "youtube"}
|
||||||
|
|
||||||
type Planner struct {
|
type Planner struct {
|
||||||
PortedSet map[string]bool
|
PortedSet map[string]bool
|
||||||
|
|
@ -48,7 +48,7 @@ func NewPlanner(portedEngines []string) *Planner {
|
||||||
|
|
||||||
// Plan returns:
|
// Plan returns:
|
||||||
// - localEngines: engines that are configured as ported for this service
|
// - localEngines: engines that are configured as ported for this service
|
||||||
// - upstreamEngines: engines that should be executed by upstream SearXNG
|
// - upstreamEngines: engines that should be executed by the upstream instance
|
||||||
// - requestedEngines: the (possibly inferred) requested engines list
|
// - requestedEngines: the (possibly inferred) requested engines list
|
||||||
//
|
//
|
||||||
// If the request provides an explicit `engines` parameter, we use it.
|
// If the request provides an explicit `engines` parameter, we use it.
|
||||||
|
|
@ -80,7 +80,7 @@ func (p *Planner) Plan(req contracts.SearchRequest) (localEngines, upstreamEngin
|
||||||
|
|
||||||
func inferFromCategories(categories []string) []string {
|
func inferFromCategories(categories []string) []string {
|
||||||
// Minimal mapping for the initial porting subset.
|
// Minimal mapping for the initial porting subset.
|
||||||
// This mirrors the idea of selecting from SearXNG categories without
|
// This mirrors the idea of selecting from engine categories without
|
||||||
// embedding the whole engine registry.
|
// embedding the whole engine registry.
|
||||||
set := map[string]bool{}
|
set := map[string]bool{}
|
||||||
for _, c := range categories {
|
for _, c := range categories {
|
||||||
|
|
@ -91,6 +91,7 @@ func inferFromCategories(categories []string) []string {
|
||||||
set["qwant"] = true
|
set["qwant"] = true
|
||||||
set["duckduckgo"] = true
|
set["duckduckgo"] = true
|
||||||
set["bing"] = true
|
set["bing"] = true
|
||||||
|
set["google"] = true
|
||||||
case "science", "scientific publications":
|
case "science", "scientific publications":
|
||||||
set["arxiv"] = true
|
set["arxiv"] = true
|
||||||
set["crossref"] = true
|
set["crossref"] = true
|
||||||
|
|
@ -98,6 +99,8 @@ func inferFromCategories(categories []string) []string {
|
||||||
set["github"] = true
|
set["github"] = true
|
||||||
case "social media":
|
case "social media":
|
||||||
set["reddit"] = true
|
set["reddit"] = true
|
||||||
|
case "videos":
|
||||||
|
set["youtube"] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,7 +109,7 @@ func inferFromCategories(categories []string) []string {
|
||||||
out = append(out, e)
|
out = append(out, e)
|
||||||
}
|
}
|
||||||
// stable order
|
// stable order
|
||||||
order := map[string]int{"wikipedia": 0, "braveapi": 1, "qwant": 2, "duckduckgo": 3, "bing": 4, "arxiv": 5, "crossref": 6, "github": 7, "reddit": 8}
|
order := map[string]int{"wikipedia": 0, "braveapi": 1, "qwant": 2, "duckduckgo": 3, "bing": 4, "google": 5, "arxiv": 6, "crossref": 7, "github": 8, "reddit": 9, "youtube": 10}
|
||||||
sortByOrder(out, order)
|
sortByOrder(out, order)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,11 @@ import (
|
||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
)
|
)
|
||||||
|
|
||||||
// QwantEngine implements a SearXNG-like `qwant` (web) adapter using
|
// QwantEngine implements a `qwant` (web) adapter using
|
||||||
// Qwant v3 endpoint: https://api.qwant.com/v3/search/web.
|
// Qwant v3 endpoint: https://api.qwant.com/v3/search/web.
|
||||||
//
|
//
|
||||||
// Qwant's API is not fully documented; this mirrors SearXNG's parsing logic
|
// Qwant's API is not fully documented; this implements parsing logic
|
||||||
// for the `web` category from `.agent/searxng/searx/engines/qwant.py`.
|
// for the `web` category.
|
||||||
type QwantEngine struct {
|
type QwantEngine struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
category string // "web" (JSON API) or "web-lite" (HTML fallback)
|
category string // "web" (JSON API) or "web-lite" (HTML fallback)
|
||||||
|
|
@ -37,7 +37,7 @@ func (e *QwantEngine) Search(ctx context.Context, req contracts.SearchRequest) (
|
||||||
return contracts.SearchResponse{Query: req.Query}, nil
|
return contracts.SearchResponse{Query: req.Query}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For API parity we use SearXNG web defaults: count=10, offset=(pageno-1)*count.
|
// For API parity we use web defaults: count=10, offset=(pageno-1)*count.
|
||||||
// The engine's config field exists so we can expand to news/images/videos later.
|
// The engine's config field exists so we can expand to news/images/videos later.
|
||||||
count := e.resultsPerPage
|
count := e.resultsPerPage
|
||||||
if count <= 0 {
|
if count <= 0 {
|
||||||
|
|
@ -262,7 +262,7 @@ func (e *QwantEngine) searchWebLite(ctx context.Context, req contracts.SearchReq
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// In SearXNG: "./span[contains(@class, 'url partner')]"
|
// Selector: "./span[contains(@class, 'url partner')]"
|
||||||
urlText := strings.TrimSpace(item.Find("span.url.partner").First().Text())
|
urlText := strings.TrimSpace(item.Find("span.url.partner").First().Text())
|
||||||
if urlText == "" {
|
if urlText == "" {
|
||||||
// fallback: any span with class containing both 'url' and 'partner'
|
// fallback: any span with class containing both 'url' and 'partner'
|
||||||
|
|
|
||||||
178
internal/engines/youtube.go
Normal file
178
internal/engines/youtube.go
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
package engines
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/metamorphosis-dev/kafka/internal/contracts"
|
||||||
|
)
|
||||||
|
|
||||||
|
type YouTubeEngine struct {
|
||||||
|
client *http.Client
|
||||||
|
apiKey string
|
||||||
|
baseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *YouTubeEngine) Name() string { return "youtube" }
|
||||||
|
|
||||||
|
func (e *YouTubeEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) {
|
||||||
|
if strings.TrimSpace(req.Query) == "" {
|
||||||
|
return contracts.SearchResponse{Query: req.Query}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.apiKey == "" {
|
||||||
|
e.apiKey = os.Getenv("YOUTUBE_API_KEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
maxResults := 10
|
||||||
|
if req.Pageno > 1 {
|
||||||
|
maxResults = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
u := e.baseURL + "/youtube/v3/search?" + url.Values{
|
||||||
|
"part": {"snippet"},
|
||||||
|
"q": {req.Query},
|
||||||
|
"type": {"video"},
|
||||||
|
"maxResults": {fmt.Sprintf("%d", maxResults)},
|
||||||
|
"key": {e.apiKey},
|
||||||
|
}.Encode()
|
||||||
|
|
||||||
|
if req.Language != "" && req.Language != "auto" {
|
||||||
|
lang := strings.Split(strings.ToLower(req.Language), "-")[0]
|
||||||
|
u += "&relevanceLanguage=" + lang
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.SearchResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := e.client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.SearchResponse{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
return contracts.SearchResponse{}, fmt.Errorf("youtube api error: status=%d body=%q", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiResp youtubeSearchResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||||
|
return contracts.SearchResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiResp.Error != nil {
|
||||||
|
return contracts.SearchResponse{}, fmt.Errorf("youtube api error: %s", apiResp.Error.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]contracts.MainResult, 0, len(apiResp.Items))
|
||||||
|
for _, item := range apiResp.Items {
|
||||||
|
if item.ID.VideoID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
videoURL := "https://www.youtube.com/watch?v=" + item.ID.VideoID
|
||||||
|
urlPtr := videoURL
|
||||||
|
|
||||||
|
published := ""
|
||||||
|
if item.Snippet.PublishedAt != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, item.Snippet.PublishedAt); err == nil {
|
||||||
|
published = t.Format("Jan 2, 2006")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content := item.Snippet.Description
|
||||||
|
if len(content) > 300 {
|
||||||
|
content = content[:300] + "..."
|
||||||
|
}
|
||||||
|
if published != "" {
|
||||||
|
content = "Published " + published + " · " + content
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnail := ""
|
||||||
|
if item.Snippet.Thumbnails.High.URL != "" {
|
||||||
|
thumbnail = item.Snippet.Thumbnails.High.URL
|
||||||
|
} else if item.Snippet.Thumbnails.Medium.URL != "" {
|
||||||
|
thumbnail = item.Snippet.Thumbnails.Medium.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, contracts.MainResult{
|
||||||
|
Template: "videos.html",
|
||||||
|
Title: item.Snippet.Title,
|
||||||
|
URL: &urlPtr,
|
||||||
|
Content: content,
|
||||||
|
Thumbnail: thumbnail,
|
||||||
|
Engine: "youtube",
|
||||||
|
Score: 1.0,
|
||||||
|
Category: "videos",
|
||||||
|
Engines: []string{"youtube"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return contracts.SearchResponse{
|
||||||
|
Query: req.Query,
|
||||||
|
NumberOfResults: len(results),
|
||||||
|
Results: results,
|
||||||
|
Answers: []map[string]any{},
|
||||||
|
Corrections: []string{},
|
||||||
|
Infoboxes: []map[string]any{},
|
||||||
|
Suggestions: []string{},
|
||||||
|
UnresponsiveEngines: [][2]string{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// YouTube API response types.
|
||||||
|
|
||||||
|
type youtubeSearchResponse struct {
|
||||||
|
Items []youtubeSearchItem `json:"items"`
|
||||||
|
PageInfo struct {
|
||||||
|
TotalResults int `json:"totalResults"`
|
||||||
|
ResultsPerPage int `json:"resultsPerPage"`
|
||||||
|
} `json:"pageInfo"`
|
||||||
|
NextPageToken string `json:"nextPageToken"`
|
||||||
|
Error *struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Errors []struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"errors"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type youtubeSearchItem struct {
|
||||||
|
ID struct {
|
||||||
|
VideoID string `json:"videoId"`
|
||||||
|
} `json:"id"`
|
||||||
|
Snippet struct {
|
||||||
|
PublishedAt string `json:"publishedAt"`
|
||||||
|
ChannelID string `json:"channelId"`
|
||||||
|
ChannelTitle string `json:"channelTitle"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Thumbnails struct {
|
||||||
|
Default struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"default"`
|
||||||
|
Medium struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"medium"`
|
||||||
|
High struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"high"`
|
||||||
|
} `json:"thumbnails"`
|
||||||
|
ResourceID struct {
|
||||||
|
VideoID string `json:"videoId"`
|
||||||
|
} `json:"resourceId"`
|
||||||
|
} `json:"snippet"`
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ import (
|
||||||
"github.com/metamorphosis-dev/kafka/internal/contracts"
|
"github.com/metamorphosis-dev/kafka/internal/contracts"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MergeResponses merges multiple SearXNG-compatible JSON responses.
|
// MergeResponses merges multiple compatible JSON responses.
|
||||||
//
|
//
|
||||||
// MVP merge semantics:
|
// MVP merge semantics:
|
||||||
// - results are concatenated with a simple de-dup key (engine|title|url)
|
// - results are concatenated with a simple de-dup key (engine|title|url)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import (
|
||||||
var languageCodeRe = regexp.MustCompile(`^[a-z]{2,3}(-[a-zA-Z]{2})?$`)
|
var languageCodeRe = regexp.MustCompile(`^[a-z]{2,3}(-[a-zA-Z]{2})?$`)
|
||||||
|
|
||||||
func ParseSearchRequest(r *http.Request) (SearchRequest, error) {
|
func ParseSearchRequest(r *http.Request) (SearchRequest, error) {
|
||||||
// SearXNG supports both GET and POST and relies on form values for routing.
|
// Supports both GET and POST and relies on form values for routing.
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
return SearchRequest{}, errors.New("invalid request: cannot parse form")
|
return SearchRequest{}, errors.New("invalid request: cannot parse form")
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +90,7 @@ func ParseSearchRequest(r *http.Request) (SearchRequest, error) {
|
||||||
// engines is an explicit list of engine names.
|
// engines is an explicit list of engine names.
|
||||||
engines := splitCSV(strings.TrimSpace(r.FormValue("engines")))
|
engines := splitCSV(strings.TrimSpace(r.FormValue("engines")))
|
||||||
|
|
||||||
// categories and category_<name> params mirror SearXNG's webadapter parsing.
|
// categories and category_<name> params mirror the webadapter parsing.
|
||||||
// We don't validate against a registry here; we just preserve the requested values.
|
// We don't validate against a registry here; we just preserve the requested values.
|
||||||
catSet := map[string]bool{}
|
catSet := map[string]bool{}
|
||||||
if catsParam := strings.TrimSpace(r.FormValue("categories")); catsParam != "" {
|
if catsParam := strings.TrimSpace(r.FormValue("categories")); catsParam != "" {
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ func WriteSearchResponse(w http.ResponseWriter, format OutputFormat, resp Search
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// csvRowHeader matches the SearXNG CSV writer key order.
|
// csvRowHeader matches the CSV writer key order.
|
||||||
var csvRowHeader = []string{"title", "url", "content", "host", "engine", "score", "type"}
|
var csvRowHeader = []string{"title", "url", "content", "host", "engine", "score", "type"}
|
||||||
|
|
||||||
func writeCSV(w http.ResponseWriter, resp SearchResponse) error {
|
func writeCSV(w http.ResponseWriter, resp SearchResponse) error {
|
||||||
|
|
@ -111,14 +111,14 @@ func writeCSV(w http.ResponseWriter, resp SearchResponse) error {
|
||||||
|
|
||||||
func writeRSS(w http.ResponseWriter, resp SearchResponse) error {
|
func writeRSS(w http.ResponseWriter, resp SearchResponse) error {
|
||||||
q := resp.Query
|
q := resp.Query
|
||||||
escapedTitle := xmlEscape("SearXNG search: " + q)
|
escapedTitle := xmlEscape("kafka search: " + q)
|
||||||
escapedDesc := xmlEscape("Search results for \"" + q + "\" - SearXNG")
|
escapedDesc := xmlEscape("Search results for \"" + q + "\" - kafka")
|
||||||
escapedQueryTerms := xmlEscape(q)
|
escapedQueryTerms := xmlEscape(q)
|
||||||
|
|
||||||
link := "/search?q=" + url.QueryEscape(q)
|
link := "/search?q=" + url.QueryEscape(q)
|
||||||
opensearchQuery := fmt.Sprintf(`<opensearch:Query role="request" searchTerms="%s" startPage="1" />`, escapedQueryTerms)
|
opensearchQuery := fmt.Sprintf(`<opensearch:Query role="request" searchTerms="%s" startPage="1" />`, escapedQueryTerms)
|
||||||
|
|
||||||
// SearXNG template uses the number of results for both totalResults and itemsPerPage.
|
// The template uses the number of results for both totalResults and itemsPerPage.
|
||||||
nr := resp.NumberOfResults
|
nr := resp.NumberOfResults
|
||||||
|
|
||||||
var items bytes.Buffer
|
var items bytes.Buffer
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,17 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/metamorphosis-dev/kafka/internal/cache"
|
"github.com/metamorphosis-dev/kafka/internal/cache"
|
||||||
|
"github.com/metamorphosis-dev/kafka/internal/config"
|
||||||
"github.com/metamorphosis-dev/kafka/internal/contracts"
|
"github.com/metamorphosis-dev/kafka/internal/contracts"
|
||||||
"github.com/metamorphosis-dev/kafka/internal/engines"
|
"github.com/metamorphosis-dev/kafka/internal/engines"
|
||||||
"github.com/metamorphosis-dev/kafka/internal/upstream"
|
"github.com/metamorphosis-dev/kafka/internal/upstream"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServiceConfig struct {
|
type ServiceConfig struct {
|
||||||
UpstreamURL string
|
UpstreamURL string
|
||||||
HTTPTimeout time.Duration
|
HTTPTimeout time.Duration
|
||||||
Cache *cache.Cache
|
Cache *cache.Cache
|
||||||
|
EnginesConfig *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
|
|
@ -44,13 +46,13 @@ func NewService(cfg ServiceConfig) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
upstreamClient: up,
|
upstreamClient: up,
|
||||||
planner: engines.NewPlannerFromEnv(),
|
planner: engines.NewPlannerFromEnv(),
|
||||||
localEngines: engines.NewDefaultPortedEngines(httpClient),
|
localEngines: engines.NewDefaultPortedEngines(httpClient, cfg.EnginesConfig),
|
||||||
cache: cfg.Cache,
|
cache: cfg.Cache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search executes the request against local engines (in parallel) and
|
// Search executes the request against local engines (in parallel) and
|
||||||
// optionally upstream SearXNG for unported engines.
|
// optionally the upstream instance for unported engines.
|
||||||
//
|
//
|
||||||
// Individual engine failures are reported as unresponsive_engines rather
|
// Individual engine failures are reported as unresponsive_engines rather
|
||||||
// than aborting the entire search.
|
// than aborting the entire search.
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ func (c *Client) SearchJSON(ctx context.Context, req contracts.SearchRequest, en
|
||||||
|
|
||||||
for engineName, kv := range req.EngineData {
|
for engineName, kv := range req.EngineData {
|
||||||
for key, value := range kv {
|
for key, value := range kv {
|
||||||
// Mirror SearXNG's naming: `engine_data-<engine>-<key>=<value>`
|
// Mirror the naming convention: `engine_data-<engine>-<key>=<value>`
|
||||||
form.Set(fmt.Sprintf("engine_data-%s-%s", engineName, key), value)
|
form.Set(fmt.Sprintf("engine_data-%s-%s", engineName, key), value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
/* kafka — clean, minimal search engine CSS */
|
/* kafka — clean, minimal search engine CSS */
|
||||||
/* Inspired by SearXNG's simple theme class conventions */
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--color-base: #f5f5f5;
|
--color-base: #f5f5f5;
|
||||||
|
|
@ -421,6 +420,63 @@ footer a:hover {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Autocomplete dropdown */
|
||||||
|
#search {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#autocomplete-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--color-base-background);
|
||||||
|
border: 1px solid var(--color-search-border);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 var(--radius) var(--radius);
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 100;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#autocomplete-dropdown.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-suggestion {
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-base-font);
|
||||||
|
border-bottom: 1px solid var(--color-result-border);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-suggestion:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-suggestion:hover,
|
||||||
|
.autocomplete-suggestion.active {
|
||||||
|
background: var(--color-header-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-suggestion mark {
|
||||||
|
background: none;
|
||||||
|
color: var(--color-link);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-footer {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-suggestion);
|
||||||
|
border-top: 1px solid var(--color-result-border);
|
||||||
|
background: var(--color-header-background);
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
#results {
|
#results {
|
||||||
|
|
@ -457,3 +513,318 @@ footer a:hover {
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Settings Panel
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.site-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
background: var(--color-header-background);
|
||||||
|
border-bottom: 1px solid var(--color-header-border);
|
||||||
|
}
|
||||||
|
.site-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-base-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gear trigger button */
|
||||||
|
.settings-trigger {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--color-base-font);
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s, background 0.2s;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.settings-trigger:hover,
|
||||||
|
.settings-trigger[aria-expanded="true"] {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--color-sidebar-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Popover panel */
|
||||||
|
.settings-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
width: 280px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-base-background);
|
||||||
|
border: 1px solid var(--color-sidebar-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: 200;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.settings-popover[data-open="true"] {
|
||||||
|
display: flex;
|
||||||
|
animation: settings-slide-in 0.2s ease;
|
||||||
|
}
|
||||||
|
@keyframes settings-slide-in {
|
||||||
|
from { opacity: 0; transform: translateY(-8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-popover-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-sidebar-border);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.settings-popover-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-base-font);
|
||||||
|
opacity: 0.6;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.settings-popover-close:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.settings-popover-body {
|
||||||
|
padding: 0.8rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-title {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-suggestion);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme buttons */
|
||||||
|
.theme-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.theme-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border: 1px solid var(--color-sidebar-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-btn-background);
|
||||||
|
color: var(--color-base-font);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.theme-btn:hover { background: var(--color-btn-hover); }
|
||||||
|
.theme-btn.active {
|
||||||
|
background: var(--color-link);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--color-link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Engine toggles — 2-column grid */
|
||||||
|
.engine-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.engine-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-sidebar-background);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.engine-toggle input[type="checkbox"] {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--color-link);
|
||||||
|
}
|
||||||
|
.engine-toggle span {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search defaults */
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
.setting-row label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.setting-row select {
|
||||||
|
width: 110px;
|
||||||
|
padding: 0.3rem 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border: 1px solid var(--color-sidebar-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-base-background);
|
||||||
|
color: var(--color-base-font);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mid-search notice */
|
||||||
|
.settings-notice {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-suggestion);
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme via data-theme attribute */
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme via data-theme attribute - explicit reset */
|
||||||
|
html[data-theme="light"] {
|
||||||
|
--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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: Bottom sheet + FAB trigger */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
/* Hide desktop trigger, show FAB */
|
||||||
|
.settings-trigger-desktop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.settings-trigger-mobile {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.settings-popover {
|
||||||
|
position: fixed;
|
||||||
|
top: auto;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
border-radius: var(--radius) var(--radius) 0 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
/* FAB: fixed bottom-right button visible only on mobile */
|
||||||
|
.settings-trigger-mobile {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-link);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
z-index: 199;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Video result cards */
|
||||||
|
.video-result {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-result .result_thumbnail {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-result .result_thumbnail img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-result .result_content_wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
|
||||||
271
internal/views/static/js/settings.js
Normal file
271
internal/views/static/js/settings.js
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
var ALL_ENGINES = [
|
||||||
|
'wikipedia', 'arxiv', 'crossref', 'braveapi',
|
||||||
|
'qwant', 'duckduckgo', 'github', 'reddit', 'bing'
|
||||||
|
];
|
||||||
|
|
||||||
|
var DEFAULT_PREFS = {
|
||||||
|
theme: 'system',
|
||||||
|
engines: ALL_ENGINES.slice(),
|
||||||
|
safeSearch: 'moderate',
|
||||||
|
format: 'html'
|
||||||
|
};
|
||||||
|
|
||||||
|
var STORAGE_KEY = 'kafka_prefs';
|
||||||
|
|
||||||
|
function loadPrefs() {
|
||||||
|
var stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
var prefs = DEFAULT_PREFS;
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
var parsed = JSON.parse(stored);
|
||||||
|
prefs = {
|
||||||
|
theme: parsed.theme || DEFAULT_PREFS.theme,
|
||||||
|
engines: parsed.engines || DEFAULT_PREFS.engines.slice(),
|
||||||
|
safeSearch: parsed.safeSearch || DEFAULT_PREFS.safeSearch,
|
||||||
|
format: parsed.format || DEFAULT_PREFS.format
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
prefs = DEFAULT_PREFS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePrefs(prefs) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
if (theme === 'system') {
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncEngineInput(prefs) {
|
||||||
|
var input = document.getElementById('engines-input');
|
||||||
|
if (input) {
|
||||||
|
input.value = prefs.engines.join(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel() {
|
||||||
|
var popover = document.getElementById('settings-popover');
|
||||||
|
var trigger = document.getElementById('settings-trigger');
|
||||||
|
if (popover) {
|
||||||
|
popover.setAttribute('data-open', 'false');
|
||||||
|
}
|
||||||
|
if (trigger) {
|
||||||
|
trigger.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
if (trigger) {
|
||||||
|
trigger.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPanel() {
|
||||||
|
var popover = document.getElementById('settings-popover');
|
||||||
|
var trigger = document.getElementById('settings-trigger');
|
||||||
|
if (popover) {
|
||||||
|
popover.setAttribute('data-open', 'true');
|
||||||
|
}
|
||||||
|
if (trigger) {
|
||||||
|
trigger.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
var firstFocusable = popover ? popover.querySelector('button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])') : null;
|
||||||
|
if (firstFocusable) {
|
||||||
|
firstFocusable.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel(prefs) {
|
||||||
|
var panel = document.getElementById('settings-popover');
|
||||||
|
if (!panel) return;
|
||||||
|
var body = panel.querySelector('.settings-popover-body');
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
var themeBtns = '';
|
||||||
|
['light', 'dark', 'system'].forEach(function(t) {
|
||||||
|
var icons = { light: '\u2600', dark: '\u263D', system: '\u2318' };
|
||||||
|
var labels = { light: 'Light', dark: 'Dark', system: 'System' };
|
||||||
|
var active = prefs.theme === t ? ' active' : '';
|
||||||
|
themeBtns += '<button class="theme-btn' + active + '" data-theme="' + t + '">' + icons[t] + ' ' + labels[t] + '</button>';
|
||||||
|
});
|
||||||
|
|
||||||
|
var engineToggles = '';
|
||||||
|
ALL_ENGINES.forEach(function(name) {
|
||||||
|
var checked = prefs.engines.indexOf(name) !== -1 ? ' checked' : '';
|
||||||
|
engineToggles += '<label class="engine-toggle"><input type="checkbox" value="' + escapeHtml(name) + '"' + checked + '><span>' + escapeHtml(name) + '</span></label>';
|
||||||
|
});
|
||||||
|
|
||||||
|
var ssOptions = [
|
||||||
|
{ val: 'moderate', label: 'Moderate' },
|
||||||
|
{ val: 'strict', label: 'Strict' },
|
||||||
|
{ val: 'off', label: 'Off' }
|
||||||
|
];
|
||||||
|
var fmtOptions = [
|
||||||
|
{ val: 'html', label: 'HTML' },
|
||||||
|
{ val: 'json', label: 'JSON' },
|
||||||
|
{ val: 'csv', label: 'CSV' },
|
||||||
|
{ val: 'rss', label: 'RSS' }
|
||||||
|
];
|
||||||
|
var ssOptionsHtml = '';
|
||||||
|
var fmtOptionsHtml = '';
|
||||||
|
ssOptions.forEach(function(o) {
|
||||||
|
var sel = prefs.safeSearch === o.val ? ' selected' : '';
|
||||||
|
ssOptionsHtml += '<option value="' + o.val + '"' + sel + '>' + o.label + '</option>';
|
||||||
|
});
|
||||||
|
fmtOptions.forEach(function(o) {
|
||||||
|
var sel = prefs.format === o.val ? ' selected' : '';
|
||||||
|
fmtOptionsHtml += '<option value="' + o.val + '"' + sel + '>' + o.label + '</option>';
|
||||||
|
});
|
||||||
|
|
||||||
|
body.innerHTML =
|
||||||
|
'<div class="settings-section">' +
|
||||||
|
'<div class="settings-section-title">Appearance</div>' +
|
||||||
|
'<div class="theme-buttons">' + themeBtns + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="settings-section">' +
|
||||||
|
'<div class="settings-section-title">Engines</div>' +
|
||||||
|
'<div class="engine-grid">' + engineToggles + '</div>' +
|
||||||
|
'<p class="settings-notice">Engine changes apply to your next search.</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="settings-section">' +
|
||||||
|
'<div class="settings-section-title">Search Defaults</div>' +
|
||||||
|
'<div class="setting-row">' +
|
||||||
|
'<label for="pref-safesearch">Safe search</label>' +
|
||||||
|
'<select id="pref-safesearch">' + ssOptionsHtml + '</select>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="setting-row">' +
|
||||||
|
'<label for="pref-format">Default format</label>' +
|
||||||
|
'<select id="pref-format">' + fmtOptionsHtml + '</select>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
// Theme buttons
|
||||||
|
var themeBtnEls = panel.querySelectorAll('.theme-btn');
|
||||||
|
for (var i = 0; i < themeBtnEls.length; i++) {
|
||||||
|
themeBtnEls[i].addEventListener('click', (function(btn) {
|
||||||
|
return function() {
|
||||||
|
prefs.theme = btn.getAttribute('data-theme');
|
||||||
|
savePrefs(prefs);
|
||||||
|
applyTheme(prefs.theme);
|
||||||
|
syncEngineInput(prefs);
|
||||||
|
renderPanel(prefs);
|
||||||
|
};
|
||||||
|
})(themeBtnEls[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engine checkboxes
|
||||||
|
var checkboxes = panel.querySelectorAll('.engine-toggle input[type="checkbox"]');
|
||||||
|
for (var j = 0; j < checkboxes.length; j++) {
|
||||||
|
checkboxes[j].addEventListener('change', (function(cb) {
|
||||||
|
return function() {
|
||||||
|
var checked = Array.prototype.slice.call(panel.querySelectorAll('.engine-toggle input[type="checkbox"]:checked')).map(function(el) { return el.value; });
|
||||||
|
if (checked.length === 0) { renderPanel(prefs); return; }
|
||||||
|
prefs.engines = checked;
|
||||||
|
savePrefs(prefs);
|
||||||
|
syncEngineInput(prefs);
|
||||||
|
};
|
||||||
|
})(checkboxes[j]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe search
|
||||||
|
var ssEl = panel.querySelector('#pref-safesearch');
|
||||||
|
if (ssEl) {
|
||||||
|
ssEl.addEventListener('change', function() {
|
||||||
|
prefs.safeSearch = ssEl.value;
|
||||||
|
savePrefs(prefs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format
|
||||||
|
var fmtEl = panel.querySelector('#pref-format');
|
||||||
|
if (fmtEl) {
|
||||||
|
fmtEl.addEventListener('change', function() {
|
||||||
|
prefs.format = fmtEl.value;
|
||||||
|
savePrefs(prefs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
var closeBtn = panel.querySelector('.settings-popover-close');
|
||||||
|
if (closeBtn) closeBtn.addEventListener('click', closePanel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSettings() {
|
||||||
|
var prefs = loadPrefs();
|
||||||
|
applyTheme(prefs.theme);
|
||||||
|
syncEngineInput(prefs);
|
||||||
|
renderPanel(prefs);
|
||||||
|
|
||||||
|
var trigger = document.getElementById('settings-trigger');
|
||||||
|
var triggerMobile = document.getElementById('settings-trigger-mobile');
|
||||||
|
|
||||||
|
function togglePanel() {
|
||||||
|
var popover = document.getElementById('settings-popover');
|
||||||
|
if (popover && popover.getAttribute('data-open') === 'true') {
|
||||||
|
closePanel();
|
||||||
|
} else {
|
||||||
|
openPanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trigger) {
|
||||||
|
trigger.addEventListener('click', togglePanel);
|
||||||
|
}
|
||||||
|
if (triggerMobile) {
|
||||||
|
triggerMobile.addEventListener('click', togglePanel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape key handler
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
var popover = document.getElementById('settings-popover');
|
||||||
|
if (popover && popover.getAttribute('data-open') === 'true') {
|
||||||
|
closePanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside handler
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var popover = document.getElementById('settings-popover');
|
||||||
|
var trigger = document.getElementById('settings-trigger');
|
||||||
|
if (popover && popover.getAttribute('data-open') === 'true') {
|
||||||
|
if (!popover.contains(e.target) && (!trigger || !trigger.contains(e.target))) {
|
||||||
|
closePanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus trap
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
var popover = document.getElementById('settings-popover');
|
||||||
|
if (popover && popover.getAttribute('data-open') === 'true') {
|
||||||
|
var focusableElements = popover.querySelectorAll('button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])');
|
||||||
|
var firstEl = focusableElements[0];
|
||||||
|
var lastEl = focusableElements[focusableElements.length - 1];
|
||||||
|
if (e.shiftKey && document.activeElement === firstEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastEl.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === lastEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
firstEl.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initSettings);
|
||||||
|
} else {
|
||||||
|
initSettings();
|
||||||
|
}
|
||||||
|
|
@ -14,12 +14,147 @@
|
||||||
<link title="kafka" type="application/opensearchdescription+xml" rel="search" href="/opensearch.xml">
|
<link title="kafka" type="application/opensearchdescription+xml" rel="search" href="/opensearch.xml">
|
||||||
</head>
|
</head>
|
||||||
<body class="{{if .Query}}search_on_results{{end}}">
|
<body class="{{if .Query}}search_on_results{{end}}">
|
||||||
|
<header class="site-header">
|
||||||
|
<span class="site-title">kafka</span>
|
||||||
|
<!-- Desktop trigger (hidden on mobile via CSS) -->
|
||||||
|
<button id="settings-trigger" class="settings-trigger settings-trigger-desktop"
|
||||||
|
aria-label="Preferences" aria-expanded="false" aria-controls="settings-popover">⚙</button>
|
||||||
|
</header>
|
||||||
|
<!-- Mobile FAB trigger (shown only on mobile via CSS) -->
|
||||||
|
<button id="settings-trigger-mobile" class="settings-trigger settings-trigger-mobile"
|
||||||
|
aria-label="Preferences" aria-expanded="false" aria-controls="settings-popover"
|
||||||
|
style="display:none;">⚙</button>
|
||||||
<main>
|
<main>
|
||||||
{{template "content" .}}
|
{{template "content" .}}
|
||||||
</main>
|
</main>
|
||||||
<footer>
|
<footer>
|
||||||
<p>Powered by <a href="https://git.ashisgreat.xyz/penal-colony/kafka">kafka</a> — a privacy-respecting, open metasearch engine</p>
|
<p>Powered by <a href="https://git.ashisgreat.xyz/penal-colony/kafka">kafka</a> — a privacy-respecting, open metasearch engine</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
<script src="/static/js/settings.js"></script>
|
||||||
|
<div id="settings-popover" data-open="false" role="dialog" aria-label="Preferences" aria-modal="true">
|
||||||
|
<div class="settings-popover-header">
|
||||||
|
Preferences
|
||||||
|
<button class="settings-popover-close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-popover-body"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var input = document.getElementById('q');
|
||||||
|
var dropdown = document.getElementById('autocomplete-dropdown');
|
||||||
|
var form = document.getElementById('search-form');
|
||||||
|
var debounceTimer = null;
|
||||||
|
var suggestions = [];
|
||||||
|
var activeIndex = -1;
|
||||||
|
var fetchController = null;
|
||||||
|
|
||||||
|
// Escape regex special chars for highlight matching
|
||||||
|
function escapeRegex(str) {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight matching prefix
|
||||||
|
function highlight(text, query) {
|
||||||
|
if (!query) return text;
|
||||||
|
var re = new RegExp('^(' + escapeRegex(query) + ')', 'i');
|
||||||
|
return text.replace(re, '<mark>$1</mark>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDropdown() {
|
||||||
|
if (suggestions.length === 0) {
|
||||||
|
dropdown.classList.remove('open');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < suggestions.length; i++) {
|
||||||
|
var escaped = highlight(suggestions[i], input.value);
|
||||||
|
html += '<div class="autocomplete-suggestion" data-index="' + i + '">' + escaped + '</div>';
|
||||||
|
}
|
||||||
|
html += '<div class="autocomplete-footer">Press <kbd>↑</kbd><kbd>↓</kbd> to navigate, Enter to select, Esc to close</div>';
|
||||||
|
dropdown.innerHTML = html;
|
||||||
|
dropdown.classList.add('open');
|
||||||
|
activeIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
dropdown.classList.remove('open');
|
||||||
|
suggestions = [];
|
||||||
|
activeIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSuggestion(index) {
|
||||||
|
if (index < 0 || index >= suggestions.length) return;
|
||||||
|
input.value = suggestions[index];
|
||||||
|
closeDropdown();
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActive(newIndex) {
|
||||||
|
var items = dropdown.querySelectorAll('.autocomplete-suggestion');
|
||||||
|
items.forEach(function (el) { el.classList.remove('active'); });
|
||||||
|
if (newIndex >= 0 && newIndex < items.length) {
|
||||||
|
items[newIndex].classList.add('active');
|
||||||
|
items[newIndex].scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
activeIndex = newIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchSuggestions(query) {
|
||||||
|
if (fetchController) fetchController.abort();
|
||||||
|
fetchController = new AbortController();
|
||||||
|
fetch('/autocompleter?q=' + encodeURIComponent(query), { signal: fetchController.signal })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
suggestions = data || [];
|
||||||
|
renderDropdown();
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
if (e.name !== 'AbortError') suggestions = [];
|
||||||
|
dropdown.classList.remove('open');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
var q = input.value.trim();
|
||||||
|
if (q.length < 2) { closeDropdown(); return; }
|
||||||
|
debounceTimer = setTimeout(function () { fetchSuggestions(q); }, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', function (e) {
|
||||||
|
if (!dropdown.classList.contains('open')) return;
|
||||||
|
var items = dropdown.querySelectorAll('.autocomplete-suggestion');
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
updateActive(Math.min(activeIndex + 1, items.length - 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
updateActive(Math.max(activeIndex - 1, -1));
|
||||||
|
} else if (e.key === 'Enter' && activeIndex >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectSuggestion(activeIndex);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('blur', function () {
|
||||||
|
// Delay to allow click events on suggestions
|
||||||
|
setTimeout(closeDropdown, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
dropdown.addEventListener('mousedown', function (e) {
|
||||||
|
var item = e.target.closest('.autocomplete-suggestion');
|
||||||
|
if (item) {
|
||||||
|
e.preventDefault(); // prevent blur from firing before select
|
||||||
|
var idx = parseInt(item.getAttribute('data-index'), 10);
|
||||||
|
selectSuggestion(idx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@
|
||||||
<div class="index">
|
<div class="index">
|
||||||
<div class="title"><h1>kafka</h1></div>
|
<div class="title"><h1>kafka</h1></div>
|
||||||
<div id="search">
|
<div id="search">
|
||||||
<form method="GET" action="/search" role="search">
|
<form method="GET" action="/search" role="search" id="search-form">
|
||||||
<input type="text" name="q" id="q" placeholder="Search…" autocomplete="off" autofocus
|
<input type="text" name="q" id="q" placeholder="Search…" autocomplete="off" autofocus
|
||||||
hx-get="/search" hx-target="#results" hx-trigger="keyup changed delay:500ms" hx-include="this">
|
hx-get="/search" hx-target="#results" hx-trigger="keyup changed delay:500ms" hx-include="this">
|
||||||
<button type="submit">Search</button>
|
<button type="submit">Search</button>
|
||||||
|
<input type="hidden" name="engines" id="engines-input" value="">
|
||||||
</form>
|
</form>
|
||||||
|
<div id="autocomplete-dropdown"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="results"></div>
|
<div id="results"></div>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
<input type="text" name="q" id="q" value="{{.Query}}" autocomplete="off"
|
<input type="text" name="q" id="q" value="{{.Query}}" autocomplete="off"
|
||||||
hx-get="/search" hx-target="#results" hx-trigger="keyup changed delay:500ms" hx-include="this">
|
hx-get="/search" hx-target="#results" hx-trigger="keyup changed delay:500ms" hx-include="this">
|
||||||
<button type="submit">Search</button>
|
<button type="submit">Search</button>
|
||||||
|
<input type="hidden" name="engines" id="engines-input" value="">
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,12 @@
|
||||||
<div id="urls" role="main">
|
<div id="urls" role="main">
|
||||||
{{if .Results}}
|
{{if .Results}}
|
||||||
{{range .Results}}
|
{{range .Results}}
|
||||||
|
{{if eq .Template "videos"}}
|
||||||
|
{{template "video_item" .}}
|
||||||
|
{{else}}
|
||||||
{{template "result_item" .}}
|
{{template "result_item" .}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{end}}
|
||||||
{{else if not .Answers}}
|
{{else if not .Answers}}
|
||||||
<div class="no_results">
|
<div class="no_results">
|
||||||
<p>No results found.</p>
|
<p>No results found.</p>
|
||||||
|
|
|
||||||
22
internal/views/templates/video_item.html
Normal file
22
internal/views/templates/video_item.html
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{{define "video_item"}}
|
||||||
|
<article class="result video-result">
|
||||||
|
{{if .Thumbnail}}
|
||||||
|
<div class="result_thumbnail">
|
||||||
|
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src="{{.Thumbnail}}" alt="{{.Title}}" loading="lazy">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="result_content_wrapper">
|
||||||
|
<h3 class="result_header">
|
||||||
|
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a>
|
||||||
|
</h3>
|
||||||
|
{{if .Content}}
|
||||||
|
<p class="result_content">{{.Content}}</p>
|
||||||
|
{{end}}
|
||||||
|
{{if .Engine}}
|
||||||
|
<div class="result_engine"><span class="engine">{{.Engine}}</span></div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
|
@ -32,10 +32,16 @@ type PageData struct {
|
||||||
Infoboxes []InfoboxView
|
Infoboxes []InfoboxView
|
||||||
UnresponsiveEngines [][2]string
|
UnresponsiveEngines [][2]string
|
||||||
PageNumbers []PageNumber
|
PageNumbers []PageNumber
|
||||||
|
ShowHeader bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResultView is a template-friendly wrapper around a MainResult.
|
// ResultView is a template-friendly wrapper around a MainResult.
|
||||||
type ResultView contracts.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.
|
// PageNumber represents a numbered pagination button.
|
||||||
type PageNumber struct {
|
type PageNumber struct {
|
||||||
|
|
@ -65,13 +71,13 @@ func init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
tmplFull = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
|
tmplFull = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
|
||||||
"base.html", "results.html", "results_inner.html", "result_item.html",
|
"base.html", "results.html", "results_inner.html", "result_item.html", "video_item.html",
|
||||||
))
|
))
|
||||||
tmplIndex = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
|
tmplIndex = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
|
||||||
"base.html", "index.html",
|
"base.html", "index.html",
|
||||||
))
|
))
|
||||||
tmplFragment = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
|
tmplFragment = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
|
||||||
"results_inner.html", "result_item.html",
|
"results_inner.html", "result_item.html", "video_item.html",
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,7 +110,11 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageD
|
||||||
// Convert results.
|
// Convert results.
|
||||||
pd.Results = make([]ResultView, len(resp.Results))
|
pd.Results = make([]ResultView, len(resp.Results))
|
||||||
for i, r := range resp.Results {
|
for i, r := range resp.Results {
|
||||||
pd.Results[i] = ResultView(r)
|
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).
|
// Convert answers (they're map[string]any — extract string values).
|
||||||
|
|
@ -163,12 +173,13 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageD
|
||||||
// RenderIndex renders the homepage (search box only).
|
// RenderIndex renders the homepage (search box only).
|
||||||
func RenderIndex(w http.ResponseWriter) error {
|
func RenderIndex(w http.ResponseWriter) error {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
return tmplIndex.ExecuteTemplate(w, "base", PageData{})
|
return tmplIndex.ExecuteTemplate(w, "base", PageData{ShowHeader: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderSearch renders the full search results page (with base layout).
|
// RenderSearch renders the full search results page (with base layout).
|
||||||
func RenderSearch(w http.ResponseWriter, data PageData) error {
|
func RenderSearch(w http.ResponseWriter, data PageData) error {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
data.ShowHeader = true
|
||||||
return tmplFull.ExecuteTemplate(w, "base", data)
|
return tmplFull.ExecuteTemplate(w, "base", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue