From b2cca0a346be5167bbd594191ac81f1519e4e253 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 11:24:43 +0000 Subject: [PATCH 001/100] ci: remove stale vendor directory before build --- .forgejo/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml index bd05693..caf4f3f 100644 --- a/.forgejo/workflows/test.yml +++ b/.forgejo/workflows/test.yml @@ -18,5 +18,8 @@ jobs: with: go-version-file: go.mod + - name: Clean vendor + run: rm -rf vendor + - name: Test run: go test -race -v ./... From a85d8033c7d636ebd65d8d0f26797b631252197f Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 11:28:31 +0000 Subject: [PATCH 002/100] fix(flake): remove stale vendorHash; auto-compute on next build The go.mod changes (goquery downgrade, x/net replace) invalidate the old vendorHash. Set to empty to auto-recompute, then replace with the actual hash from the build error. --- flake.nix | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index d143495..fc8613b 100644 --- a/flake.nix +++ b/flake.nix @@ -21,13 +21,16 @@ version = "0.1.0"; src = ./.; - vendorHash = "sha256-NbAa4QM/TI3BTuZs4glx9k3ZjSl2/2LQfKlQ7izR8Ho="; + vendorHash = ""; # auto-computed; update with actual hash after first build # Run: nix build .#packages.x86_64-linux.default - # It will fail with the correct hash. Replace it here. + # It will fail with the correct hash. Replace vendorHash with it. # Embed the templates and static files at build time. ldflags = [ "-s" "-w" ]; + # Remove stale vendor directory when dependencies change. + preBuild = "rm -rf vendor"; + nativeCheckInputs = with pkgs; [ ]; # Tests require network; they run in CI instead. From 25757fdb996f1c532f35093f4531480ee9d84e8f Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 11:33:22 +0000 Subject: [PATCH 003/100] ci: add GitHub Actions workflow for pull requests Runs tests on PRs and pushes to main. --- .github/workflows/test.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f1770a9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Tests + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Clean vendor + run: rm -rf vendor + + - name: Test + run: go test -race -v ./... From 7d0e2017cdfa65a8261fbb494d655a27e48e6cad Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 11:33:29 +0000 Subject: [PATCH 004/100] fix(go.mod): remove stale replace directive The replace directive for golang.org/x/net was causing build failures when using vendorHash = "" with the Go module proxy. Co-Authored-By: Claude Opus 4.6 --- go.mod | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.mod b/go.mod index 85f1653..f5e61cb 100644 --- a/go.mod +++ b/go.mod @@ -15,5 +15,3 @@ require ( go.uber.org/atomic v1.11.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 From 16266e143e5e523816486348a027f7a509eee01e Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 11:39:41 +0000 Subject: [PATCH 005/100] fix(go.mod): add missing x/net v0.52.0 hash to go.sum The replace directive was removed but go.sum wasn't updated with the correct hash for golang.org/x/net v0.52.0. Co-Authored-By: Claude Opus 4.6 --- go.sum | 2 ++ 1 file changed, 2 insertions(+) diff --git a/go.sum b/go.sum index 6b1b78b..796f047 100644 --- a/go.sum +++ b/go.sum @@ -95,3 +95,5 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= From 2883ac95e7984eeef063b663d646439e39898794 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 11:41:15 +0000 Subject: [PATCH 006/100] fix(go.mod): remove unused golang.org/x/net indirect dep The golang.org/x/net v0.52.0 was listed as an indirect dependency but nothing in the codebase imports it, causing go mod tidy to fail. Co-Authored-By: Claude Opus 4.6 --- go.mod | 1 - go.sum | 4 ---- 2 files changed, 5 deletions(-) diff --git a/go.mod b/go.mod index f5e61cb..d281ae2 100644 --- a/go.mod +++ b/go.mod @@ -13,5 +13,4 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/net v0.52.0 // indirect ) diff --git a/go.sum b/go.sum index 796f047..4526382 100644 --- a/go.sum +++ b/go.sum @@ -50,8 +50,6 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -95,5 +93,3 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= From 8c6d056f52a5007a4372458817da9ccf536a5ceb Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 12:41:42 +0100 Subject: [PATCH 007/100] fix(engines): cap Brave API offset to 9 to avoid 422 error Brave API only supports offset values 0-9. When pageno > 1 with resultsPerPage=20, offset exceeded this limit causing 422 errors. Co-Authored-By: Claude Opus 4.6 --- internal/engines/braveapi.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/engines/braveapi.go b/internal/engines/braveapi.go index 81d1f3b..8977cb2 100644 --- a/internal/engines/braveapi.go +++ b/internal/engines/braveapi.go @@ -80,10 +80,15 @@ func (e *BraveEngine) Search(ctx context.Context, req contracts.SearchRequest) ( return contracts.SearchResponse{Query: req.Query}, nil } + // Brave API only supports offset values 0-9 (first page of results). + // Paginating beyond the first page is not supported by Brave. offset := 0 if req.Pageno > 1 { offset = (req.Pageno - 1) * e.resultsPerPage } + if offset > 9 { + offset = 9 + } args := url.Values{} args.Set("q", q) From f6128689f1d458fe716d4df3e8ad709400d88803 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 11:42:15 +0000 Subject: [PATCH 008/100] fix(go.sum): remove stale go.sum to allow rebuild from proxy The go.sum is out of sync with go.mod causing build failures. Removing it allows Go to rebuild it from the module proxy. Co-Authored-By: Claude Opus 4.6 --- go.sum | 95 ---------------------------------------------------------- 1 file changed, 95 deletions(-) delete mode 100644 go.sum diff --git a/go.sum b/go.sum deleted file mode 100644 index 4526382..0000000 --- a/go.sum +++ /dev/null @@ -1,95 +0,0 @@ -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/PuerkitoBio/goquery v1.9.0 h1:zgjKkdpRY9T97Q5DCtcXwfqkcylSFIVCocZmn2huTp8= -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/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/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= -github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= -github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= -github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= -github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From f1cf23745e23baf15bcb7d8ac86eb6e7a50420c6 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 11:44:32 +0000 Subject: [PATCH 009/100] test: add HTTP API integration tests Test GET /healthz, /, /search, /autocompleter endpoints. Verify response codes, content types, JSON decoding, empty-query redirect, and source URL presence in footer. Also fix dead code in Search handler: the redirect for empty q was unreachable because ParseSearchRequest errors on empty q first. Move the q/format check before ParseSearchRequest to fix the redirect. --- internal/httpapi/handlers.go | 10 +- internal/httpapi/httpapi_test.go | 230 +++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 internal/httpapi/httpapi_test.go diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index cc19b4b..f8db054 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -72,17 +72,19 @@ func (h *Handler) OpenSearch(baseURL string) http.HandlerFunc { } func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { + q := r.FormValue("q") + format := r.FormValue("format") + // For HTML format with no query, redirect to homepage. - if r.FormValue("q") == "" && (r.FormValue("format") == "" || r.FormValue("format") == "html") { + if q == "" && (format == "" || format == "html") { http.Redirect(w, r, "/", http.StatusFound) return } req, err := search.ParseSearchRequest(r) if err != nil { - // For HTML, render error on the results page. - if req.Format == contracts.FormatHTML || r.FormValue("format") == "html" { - pd := views.PageData{SourceURL: h.sourceURL, Query: r.FormValue("q")} + if format == "html" || format == "" { + pd := views.PageData{SourceURL: h.sourceURL, Query: q} if views.IsHTMXRequest(r) { views.RenderSearchFragment(w, pd) } else { diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go new file mode 100644 index 0000000..f33cb8c --- /dev/null +++ b/internal/httpapi/httpapi_test.go @@ -0,0 +1,230 @@ +// kafka — a privacy-respecting metasearch engine +// Copyright (C) 2026-present metamorphosis-dev +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package httpapi_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/metamorphosis-dev/kafka/internal/contracts" + "github.com/metamorphosis-dev/kafka/internal/httpapi" + "github.com/metamorphosis-dev/kafka/internal/search" +) + +// mockUpstreamHandler returns controlled JSON responses. +func mockUpstreamJSON(query string) contracts.SearchResponse { + return contracts.SearchResponse{ + Query: query, + NumberOfResults: 2, + Results: []contracts.MainResult{ + {Title: "Upstream Result 1", URL: ptr("https://upstream.example/1"), Content: "From upstream", Engine: "upstream"}, + {Title: "Upstream Result 2", URL: ptr("https://upstream.example/2"), Content: "From upstream", Engine: "upstream"}, + }, + Answers: []map[string]any{}, + Corrections: []string{}, + Infoboxes: []map[string]any{}, + Suggestions: []string{"upstream suggestion"}, + UnresponsiveEngines: [][2]string{}, + } +} + +func ptr(s string) *string { return &s } + +func newTestServer(t *testing.T) (*httptest.Server, *httpapi.Handler) { + t.Helper() + + // Mock upstream server that returns controlled JSON. + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query := r.FormValue("q") + resp := mockUpstreamJSON(query) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + t.Cleanup(upstream.Close) + + svc := search.NewService(search.ServiceConfig{ + UpstreamURL: upstream.URL, + HTTPTimeout: 0, + Cache: nil, + EnginesConfig: nil, + }) + + h := httpapi.NewHandler(svc, nil, "https://src.example.com") + + mux := http.NewServeMux() + mux.HandleFunc("/healthz", h.Healthz) + mux.HandleFunc("/", h.Index) + mux.HandleFunc("/search", h.Search) + mux.HandleFunc("/autocompleter", h.Autocompleter) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return server, h +} + +func TestHealthz(t *testing.T) { + server, _ := newTestServer(t) + resp, err := http.Get(server.URL + "/healthz") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/plain") { + t.Errorf("expected text/plain, got %s", ct) + } +} + +func TestIndex(t *testing.T) { + server, _ := newTestServer(t) + resp, err := http.Get(server.URL + "/") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Errorf("expected text/html, got %s", ct) + } + + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), " Date: Sun, 22 Mar 2026 11:44:59 +0000 Subject: [PATCH 010/100] fix(go): run go mod tidy to sync dependencies This fixes the build by properly synchronizing go.mod and go.sum using the official Go toolchain. Co-Authored-By: Claude Opus 4.6 --- go.mod | 1 + 1 file changed, 1 insertion(+) diff --git a/go.mod b/go.mod index d281ae2..6981818 100644 --- a/go.mod +++ b/go.mod @@ -13,4 +13,5 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect go.uber.org/atomic v1.11.0 // indirect + golang.org/x/net v0.33.0 // indirect ) From bf5f36e383d4af3b32cee0dff72b556c761dcb27 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 11:47:32 +0000 Subject: [PATCH 011/100] chore(deps): add go.sum from go mod tidy --- go.sum | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 go.sum diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5700b4c --- /dev/null +++ b/go.sum @@ -0,0 +1,94 @@ +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/PuerkitoBio/goquery v1.9.0 h1:zgjKkdpRY9T97Q5DCtcXwfqkcylSFIVCocZmn2huTp8= +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/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +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/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 5e125646a71c123c03fae5d4a7de08525b6842c4 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 11:48:08 +0000 Subject: [PATCH 012/100] fix(flake): set correct vendorHash The auto-computed vendorHash for the go modules is: sha256-PTD4eEEkLGBCZbot6W4U+sMOpIbH2tcFSztQel7hyXI= Co-Authored-By: Claude Opus 4.6 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index fc8613b..d9bb322 100644 --- a/flake.nix +++ b/flake.nix @@ -21,7 +21,7 @@ version = "0.1.0"; src = ./.; - vendorHash = ""; # auto-computed; update with actual hash after first build + vendorHash = "sha256-PTD4eEEkLGBCZbot6W4U+sMOpIbH2tcFSztQel7hyXI="; # Run: nix build .#packages.x86_64-linux.default # It will fail with the correct hash. Replace vendorHash with it. From 3bc1fad6b57efce91ff98f3b7728baa67ff5f470 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 12:01:18 +0000 Subject: [PATCH 013/100] fix(flake): force remove vendor in preConfigure The nix store may have stale vendor directories with incorrect permissions. Force chmod before removing to ensure clean build. Co-Authored-By: Claude Opus 4.6 --- flake.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index d9bb322..e2521d7 100644 --- a/flake.nix +++ b/flake.nix @@ -28,8 +28,8 @@ # Embed the templates and static files at build time. ldflags = [ "-s" "-w" ]; - # Remove stale vendor directory when dependencies change. - preBuild = "rm -rf vendor"; + # Remove stale vendor directory before buildGoModule deletes it. + preConfigure = "find vendor -type f -exec chmod 666 {} \; 2>/dev/null || true; rm -rf vendor 2>/dev/null || find vendor -delete 2>/dev/null || true"; nativeCheckInputs = with pkgs; [ ]; From f172da33eff60fb0008ace35dc5f611737c84bfe Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 12:41:42 +0100 Subject: [PATCH 014/100] fix(engines): cap Brave API offset to 9 to avoid 422 error Brave API only supports offset values 0-9. When pageno > 1 with resultsPerPage=20, offset exceeded this limit causing 422 errors. Co-Authored-By: Claude Opus 4.6 --- internal/engines/braveapi.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/engines/braveapi.go b/internal/engines/braveapi.go index 81d1f3b..8977cb2 100644 --- a/internal/engines/braveapi.go +++ b/internal/engines/braveapi.go @@ -80,10 +80,15 @@ func (e *BraveEngine) Search(ctx context.Context, req contracts.SearchRequest) ( return contracts.SearchResponse{Query: req.Query}, nil } + // Brave API only supports offset values 0-9 (first page of results). + // Paginating beyond the first page is not supported by Brave. offset := 0 if req.Pageno > 1 { offset = (req.Pageno - 1) * e.resultsPerPage } + if offset > 9 { + offset = 9 + } args := url.Values{} args.Set("q", q) From 6bbde20f23725e88ef8814a724ddea517085fa7a Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:04:34 +0100 Subject: [PATCH 015/100] docs: add Brave Search frontend redesign specification Co-Authored-By: Claude Opus 4.6 --- ...26-03-22-brave-search-frontend-redesign.md | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md diff --git a/docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md b/docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md new file mode 100644 index 0000000..dcbc862 --- /dev/null +++ b/docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md @@ -0,0 +1,236 @@ +# Brave Search Frontend Redesign — Design Specification + +## Overview + +Redesign the kafka frontend to match Brave Search's clean, functional aesthetic with emphasis on layout changes: three-column results page, category tiles on homepage, and a hybrid preferences system with full-page `/preferences` route. + +## Design Principles + +1. **Brave-like layout** — Three-column results, full-page preferences, homepage tiles +2. **Preserve existing design tokens** — Keep current CSS variables (colors, spacing, radii) +3. **CSS Grid for layout** — Three-column grid for results, flexible layouts elsewhere +4. **Hybrid preferences** — Quick popover for common settings, full `/preferences` page for all options +5. **Minimal HTML changes** — Restructure templates where needed for layout, reuse existing partials + +--- + +## 1. Homepage Redesign + +### Current State +- Centered hero with logo, tagline, and search box +- No visual categorization of search types + +### New Layout +``` +┌─────────────────────────────────────────────────────────────┐ +│ [Logo] [⚙ Preferences]│ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ [🔍 Search Box] │ +│ │ +│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │ News │ │ Images │ │ Videos │ │ Maps │ ... │ +│ └────────┘ └────────┘ └────────┘ └────────┘ │ +│ │ +│ "Search the web privately..." │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Implementation +- **File:** `internal/views/templates/index.html` +- **Structure:** Search hero + category tiles grid +- **Tiles:** Static links to `/search?q=...` with category parameter (e.g., `&category=images`) +- **Categories:** News, Images, Videos, Maps, Shopping, Music (or configurable) +- **Styling:** Grid of icon+label cards below search box, subtle hover effects + +--- + +## 2. Results Page — Three-Column Layout + +### Current State +- Two columns: compact search bar spanning top, main results + right sidebar + +### New Layout +``` +┌─────────────────────────────────────────────────────────────┐ +│ [Logo] [⚙ Preferences]│ +├─────────────────────────────────────────────────────────────┤ +│ ┌─────────┐ ┌────────────────────────────┐ ┌──────────┐│ +│ │ Nav │ │ 🔍 [ Search Input ] │ │ Related ││ +│ │ ─────── │ └────────────────────────────┘ │ Searches ││ +│ │ All │ │ │ ││ +│ │ Images │ ┌──────────────────────────┐ │ │ ─────── ││ +│ │ Videos │ │ Result Card │ │ │ Suggestions│ +│ │ News │ │ Title, URL, Description │ │ │ ││ +│ │ Maps │ └──────────────────────────┘ │ │ ││ +│ │ ... │ ┌──────────────────────────┐ │ └──────────┘│ +│ │ │ │ Result Card │ │ │ +│ │ ─────── │ │ ... │ │ │ +│ │ Filters │ └──────────────────────────┘ │ │ +│ │ Time │ ... │ │ +│ │ Type │ │ │ +│ └─────────┘ [Pagination] │ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Implementation +- **Files:** `internal/views/templates/results.html`, `internal/views/templates/base.html` +- **Left Sidebar (desktop):** + - Category navigation links (All, Images, Videos, News, Maps, Shopping) + - Filters section (Time range, Result type) — collapsible + - Hidden on mobile +- **Center Column:** + - Compact search bar + - Results count meta + - Result cards (unchanged markup, restyled if needed) + - Pagination +- **Right Sidebar:** + - Related searches (existing suggestions) + - Additional panels as needed +- **CSS:** Use `display: grid` with three columns on desktop, collapse to single column on mobile + +--- + +## 3. Preferences Page — Full-Page Hybrid + +### Current State +- Popover triggered by gear icon in header +- JavaScript-rendered from localStorage +- Sections: Appearance, Engines, Search Defaults + +### New Layout +``` +┌─────────────────────────────────────────────────────────────┐ +│ [Logo] [⚙ Preferences]│ +├─────────────────────────────────────────────────────────────┤ +│ ┌────────────────┐ ┌─────────────────────────────────────┐│ +│ │ Sidebar │ │ Content ││ +│ │ ───────────── │ │ ││ +│ │ Search │ │ [Section Content] ││ +│ │ Privacy │ │ ││ +│ │ Tabs │ │ ││ +│ │ Appearance │ │ ││ +│ │ Sidebar │ │ ││ +│ │ Content │ │ ││ +│ │ Languages │ │ ││ +│ │ Regional │ │ ││ +│ └────────────────┘ └─────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────┘ +``` + +### Sections (Brave-style) +1. **Search** — Default engine, safe search, language +2. **Privacy** — Tracking protection toggle (UI only, always on), request DNT header toggle +3. **Tabs** — New tab behavior (placeholder section) +4. **Appearance** — Theme (Light/Dark/System), results font size +5. **Sidebar** — Sidebar visibility toggle +6. **Content** — Filter explicit results (SafeSearch), auto-play media toggle +7. **Languages** — UI language (English only for now), search language +8. **Regional** — Region/Country, timezone (placeholder) + +### Implementation +- **Route:** Add `GET /preferences` and `POST /preferences` to `internal/httpapi/` +- **Template:** `internal/views/templates/preferences.html` +- **Quick Settings Popover:** Keep existing popover for theme toggle and engine toggles only (lightweight, localStorage) +- **Full Preferences Page:** Server-rendered, form POST saves to localStorage, reads on load +- **Styling:** Match existing design tokens, section headers, form controls + +--- + +## 4. Component Changes + +### Header +- Logo + site name (unchanged) +- Preferences button (unchanged) + +### Search Box +- Homepage: Larger, prominent, centered +- Results page: Compact, full-width within center column + +### Result Cards +- Keep existing structure +- Consider subtle styling improvements (spacing, typography) + +### Category Tiles (Homepage) +- Icon + label per category +- Hover: slight scale + shadow + +### Left Sidebar (Results Page) +- Sticky positioning +- Category links with active state indicator +- Collapsible filter sections + +### Preferences Sidebar +- Vertical nav with section icons +- Active state indicator +- Mobile: horizontal scroll or accordion + +--- + +## 5. CSS Architecture + +### Existing (Retain) +- CSS custom properties (design tokens) +- Component-level styles +- Dark mode via `[data-theme="dark"]` + +### New +- Layout Grid for three-column results: + ```css + .results-layout { + display: grid; + grid-template-columns: 200px 1fr 240px; + gap: 2rem; + } + ``` +- Preferences page layout: + ```css + .preferences-layout { + display: grid; + grid-template-columns: 200px 1fr; + gap: 2rem; + } + ``` +- Category tiles grid: + ```css + .category-tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 1rem; + } + ``` + +--- + +## 6. Files to Modify + +| File | Change | +|------|--------| +| `internal/views/templates/index.html` | Add category tiles | +| `internal/views/templates/results.html` | Add left sidebar, restructure for three columns | +| `internal/views/templates/base.html` | Minimal changes (no structural changes needed) | +| `internal/views/templates/preferences.html` | **New** — full preferences page | +| `internal/views/static/css/kafka.css` | Add layout grids, category tiles, sidebar styles | +| `internal/views/static/js/settings.js` | Keep popover for quick settings, update for preferences page | +| `internal/httpapi/httpapi.go` | Add `/preferences` route (GET + POST) | +| `internal/views/views.go` | Add preferences template rendering | + +--- + +## 7. Priority Order + +1. **Phase 1:** CSS layout framework (three-column grid, new variables) +2. **Phase 2:** Results page three-column layout +3. **Phase 3:** Homepage category tiles +4. **Phase 4:** Preferences page (quick popover first, then full page) +5. **Phase 5:** Polish and mobile responsiveness + +--- + +## Out of Scope + +- Backend search logic changes +- New engine implementations +- Caching or performance improvements +- User authentication/account system From 79c37a086bd1df77436555ade03998fbecece48e Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 12:05:14 +0000 Subject: [PATCH 016/100] ci: update actions/checkout to v5 (uses Node 24) --- .forgejo/workflows/test.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml index caf4f3f..5f7efb4 100644 --- a/.forgejo/workflows/test.yml +++ b/.forgejo/workflows/test.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: https://github.com/actions/checkout@v4 + uses: https://github.com/actions/checkout@v5 - name: Set up Go uses: https://github.com/actions/setup-go@v5 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f1770a9..47cc920 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Go uses: actions/setup-go@v5 From e9b5fa1f0bee1faa8b154e7fea44e128d06623a5 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 12:11:35 +0000 Subject: [PATCH 017/100] docs: update license to AGPLv3 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c03019e..25c1c29 100644 --- a/README.md +++ b/README.md @@ -221,4 +221,4 @@ Includes Valkey 8 with health checks out of the box. ## License -MIT +[AGPLv3](https://www.gnu.org/licenses/agpl-3.0.html) From cb05ac5b8c243206cb3226eb86538d12c6e13b64 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:12:06 +0100 Subject: [PATCH 018/100] docs: update Brave Search frontend redesign spec with clarifications - Clarify localStorage-only preferences (no server persistence) - Expand category tiles including future ones (weather, sports, crypto) - Define filter UI options with query params (time range, result type) - Add mobile breakpoints and collapse behavior - Reduce quick popover to theme + engines only - Rename Preferences Sidebar to Preferences Nav - Add results count format specification - Add sticky positioning CSS for left sidebar Co-Authored-By: Claude Opus 4.6 --- ...26-03-22-brave-search-frontend-redesign.md | 186 +++++++++++++----- 1 file changed, 139 insertions(+), 47 deletions(-) diff --git a/docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md b/docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md index dcbc862..d30ab99 100644 --- a/docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md +++ b/docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md @@ -9,8 +9,9 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic 1. **Brave-like layout** — Three-column results, full-page preferences, homepage tiles 2. **Preserve existing design tokens** — Keep current CSS variables (colors, spacing, radii) 3. **CSS Grid for layout** — Three-column grid for results, flexible layouts elsewhere -4. **Hybrid preferences** — Quick popover for common settings, full `/preferences` page for all options +4. **Hybrid preferences** — Quick popover for common settings (theme + engines), full `/preferences` page for all options 5. **Minimal HTML changes** — Restructure templates where needed for layout, reuse existing partials +6. **localStorage-only preferences** — No server-side persistence; all preferences stored in browser localStorage --- @@ -41,9 +42,24 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic - **File:** `internal/views/templates/index.html` - **Structure:** Search hero + category tiles grid - **Tiles:** Static links to `/search?q=...` with category parameter (e.g., `&category=images`) -- **Categories:** News, Images, Videos, Maps, Shopping, Music (or configurable) - **Styling:** Grid of icon+label cards below search box, subtle hover effects +### Category Tiles +| Category | Icon | Notes | +|----------|------|-------| +| All | 🌐 | Default, no category param | +| News | 📰 | | +| Images | 🖼️ | | +| Videos | 🎬 | | +| Maps | 🗺️ | | +| Shopping | 🛒 | Future: connect to shopping engine | +| Music | 🎵 | Future: connect to music engine | +| Weather | 🌤️ | Future: connect to weather API | +| Sports | ⚽ | Future | +| Cryptocurrency | ₿ | Future | + +Categories marked "Future" are included in the UI but may not have backend support yet. Category tiles that lack backend support display grayed out with a "Coming soon" tooltip. + --- ## 2. Results Page — Three-Column Layout @@ -59,36 +75,71 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic │ ┌─────────┐ ┌────────────────────────────┐ ┌──────────┐│ │ │ Nav │ │ 🔍 [ Search Input ] │ │ Related ││ │ │ ─────── │ └────────────────────────────┘ │ Searches ││ -│ │ All │ │ │ ││ +│ │ All │ About 1,240 results (0.42s) │ ││ │ │ Images │ ┌──────────────────────────┐ │ │ ─────── ││ │ │ Videos │ │ Result Card │ │ │ Suggestions│ │ │ News │ │ Title, URL, Description │ │ │ ││ -│ │ Maps │ └──────────────────────────┘ │ │ ││ -│ │ ... │ ┌──────────────────────────┐ │ └──────────┘│ -│ │ │ │ Result Card │ │ │ -│ │ ─────── │ │ ... │ │ │ -│ │ Filters │ └──────────────────────────┘ │ │ -│ │ Time │ ... │ │ -│ │ Type │ │ │ -│ └─────────┘ [Pagination] │ │ +│ │ Maps │ └──────────────────────────┘ │ └──────────┘│ +│ │ Shopping│ ┌──────────────────────────┐ │ │ +│ │ ... │ │ Result Card │ │ │ +│ │ │ │ ... │ │ │ +│ │ ─────── │ └──────────────────────────┘ │ │ +│ │ Filters │ ... │ │ +│ │ Time │ │ │ +│ │ Type │ [Pagination] │ │ +│ └─────────┘ │ │ └─────────────────────────────────────────────────────────────┘ ``` ### Implementation - **Files:** `internal/views/templates/results.html`, `internal/views/templates/base.html` -- **Left Sidebar (desktop):** - - Category navigation links (All, Images, Videos, News, Maps, Shopping) +- **Left Sidebar (desktop, sticky):** + - Category navigation links (All, Images, Videos, News, Maps, Shopping, Music, Weather) - Filters section (Time range, Result type) — collapsible - - Hidden on mobile + - Hidden on mobile (< 768px) + - **Center Column:** - Compact search bar - - Results count meta - - Result cards (unchanged markup, restyled if needed) + - Results count meta: "About {n} results ({time}s)" + - Result cards (unchanged markup) - Pagination + - **Right Sidebar:** - Related searches (existing suggestions) - Additional panels as needed -- **CSS:** Use `display: grid` with three columns on desktop, collapse to single column on mobile + +### Filters +**Time Range Options:** +| Label | Query Param | +|-------|-------------| +| Any time | (none) | +| Past hour | `&time=h` | +| Past 24 hours | `&time=d` | +| Past week | `&time=w` | +| Past month | `&time=m` | +| Past year | `&time=y` | + +**Result Type Options:** +| Label | Query Param | +|-------|-------------| +| All results | (none) | +| News | `&type=news` | +| Videos | `&type=video` | +| Images | `&type=image` | + +Filter state persists in URL query params and is preserved across HTMX navigation via `hx-include`. + +### Mobile Behavior +| Breakpoint | Layout | +|------------|--------| +| < 768px | Single column, no left sidebar | +| 768px - 1024px | Two columns (center + right sidebar), no left nav | +| > 1024px | Full three columns | + +On mobile (< 768px): +- Category filters accessible via a horizontal scrollable chip row above results +- Both sidebars hidden +- Search bar full-width --- @@ -105,7 +156,7 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic │ [Logo] [⚙ Preferences]│ ├─────────────────────────────────────────────────────────────┤ │ ┌────────────────┐ ┌─────────────────────────────────────┐│ -│ │ Sidebar │ │ Content ││ +│ │ Nav │ │ Content ││ │ │ ───────────── │ │ ││ │ │ Search │ │ [Section Content] ││ │ │ Privacy │ │ ││ @@ -132,10 +183,14 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic ### Implementation - **Route:** Add `GET /preferences` and `POST /preferences` to `internal/httpapi/` - **Template:** `internal/views/templates/preferences.html` -- **Quick Settings Popover:** Keep existing popover for theme toggle and engine toggles only (lightweight, localStorage) -- **Full Preferences Page:** Server-rendered, form POST saves to localStorage, reads on load +- **Storage:** localStorage-only. GET handler renders page shell, JavaScript populates form values from localStorage. POST handler receives form data, writes to localStorage, re-renders page. +- **Quick Settings Popover:** Keep existing popover for **theme toggle and engine toggles only** (lightweight, localStorage). SafeSearch and Format settings move exclusively to full preferences page. - **Styling:** Match existing design tokens, section headers, form controls +### Preferences Nav (Mobile) +- Horizontal scrollable nav on mobile (< 768px) +- Active section highlighted + --- ## 4. Component Changes @@ -157,14 +212,14 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic - Hover: slight scale + shadow ### Left Sidebar (Results Page) -- Sticky positioning +- Sticky positioning (`position: sticky; top: calc(var(--header-height) + 1rem)`) - Category links with active state indicator - Collapsible filter sections -### Preferences Sidebar +### Preferences Nav - Vertical nav with section icons - Active state indicator -- Mobile: horizontal scroll or accordion +- Mobile: horizontal scroll --- @@ -176,30 +231,66 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic - Dark mode via `[data-theme="dark"]` ### New -- Layout Grid for three-column results: - ```css + +**Layout Grid for three-column results:** +```css +.results-layout { + display: grid; + grid-template-columns: 200px 1fr 240px; + gap: 2rem; + align-items: start; +} +``` + +**Sticky Left Sidebar:** +```css +.results-layout .left-sidebar { + position: sticky; + top: calc(var(--header-height) + 1.5rem); + max-height: calc(100vh - var(--header-height) - 3rem); + overflow-y: auto; +} +``` + +**Preferences page layout:** +```css +.preferences-layout { + display: grid; + grid-template-columns: 200px 1fr; + gap: 2rem; +} +``` + +**Category tiles grid:** +```css +.category-tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 1rem; +} +``` + +**Mobile breakpoints:** +```css +@media (max-width: 768px) { .results-layout { - display: grid; - grid-template-columns: 200px 1fr 240px; - gap: 2rem; + grid-template-columns: 1fr; } - ``` -- Preferences page layout: - ```css - .preferences-layout { - display: grid; - grid-template-columns: 200px 1fr; - gap: 2rem; + .results-layout .left-sidebar, + .results-layout .right-sidebar { + display: none; } - ``` -- Category tiles grid: - ```css - .category-tiles { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); - gap: 1rem; +} + +@media (min-width: 769px) and (max-width: 1024px) { + .results-layout { + grid-template-columns: 1fr 220px; } - ``` + .results-layout .left-sidebar { + display: none; + } +} +``` --- @@ -211,8 +302,8 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic | `internal/views/templates/results.html` | Add left sidebar, restructure for three columns | | `internal/views/templates/base.html` | Minimal changes (no structural changes needed) | | `internal/views/templates/preferences.html` | **New** — full preferences page | -| `internal/views/static/css/kafka.css` | Add layout grids, category tiles, sidebar styles | -| `internal/views/static/js/settings.js` | Keep popover for quick settings, update for preferences page | +| `internal/views/static/css/kafka.css` | Add layout grids, category tiles, sidebar styles, sticky positioning, mobile breakpoints | +| `internal/views/static/js/settings.js` | Reduce popover to theme + engines; add preferences page JS | | `internal/httpapi/httpapi.go` | Add `/preferences` route (GET + POST) | | `internal/views/views.go` | Add preferences template rendering | @@ -220,7 +311,7 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic ## 7. Priority Order -1. **Phase 1:** CSS layout framework (three-column grid, new variables) +1. **Phase 1:** CSS layout framework (three-column grid, new variables, breakpoints) 2. **Phase 2:** Results page three-column layout 3. **Phase 3:** Homepage category tiles 4. **Phase 4:** Preferences page (quick popover first, then full page) @@ -231,6 +322,7 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic ## Out of Scope - Backend search logic changes -- New engine implementations +- New engine implementations (category tiles for future engines are UI placeholders only) - Caching or performance improvements - User authentication/account system +- Server-side preference storage From b005e2140ef9b12de6d81b9a14893b963351d033 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:17:46 +0100 Subject: [PATCH 019/100] docs: add Brave Search frontend redesign implementation plan Co-Authored-By: Claude Opus 4.6 --- ...26-03-22-brave-search-frontend-redesign.md | 1204 +++++++++++++++++ 1 file changed, 1204 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md diff --git a/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md b/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md new file mode 100644 index 0000000..486cee4 --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md @@ -0,0 +1,1204 @@ +# Brave Search Frontend Redesign — 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:** Redesign the kafka frontend to match Brave Search's layout: three-column results page, category tiles on homepage, and a hybrid preferences system with full-page `/preferences` route. + +**Architecture:** CSS Grid for page-level layouts (three-column results, two-column preferences). JavaScript popover for quick settings (theme + engines only). Server-rendered full preferences page with localStorage persistence. Category tiles are static links with category query params. + +**Tech Stack:** Go (Go templates), CSS Grid/Flexbox, Vanilla JavaScript (HTMX for search), localStorage for preferences + +--- + +## File Map + +| File | Responsibility | +|------|----------------| +| `internal/views/static/css/kafka.css` | Add layout grids, category tiles, sidebar styles, mobile breakpoints | +| `internal/views/templates/index.html` | Add category tiles below search box | +| `internal/views/templates/results.html` | Add left sidebar, restructure for three-column grid | +| `internal/views/templates/preferences.html` | **New** — full preferences page with nav | +| `internal/views/templates/base.html` | No structural changes needed | +| `internal/views/static/js/settings.js` | Reduce popover to theme + engines; add preferences page JS | +| `internal/httpapi/handlers.go` | Add `GET /preferences` and `POST /preferences` handlers | +| `internal/views/views.go` | Add `RenderPreferences` and `tmplPreferences` template | + +--- + +## PHASE 1: CSS Layout Framework + +### Task 1: Add CSS Grid Layouts and Breakpoints + +**Files:** +- Modify: `internal/views/static/css/kafka.css` + +- [ ] **Step 1: Add three-column results layout CSS** + +Append to end of `kafka.css`, before the `@media print` block: + +```css +/* ============================================================ + Three-Column Results Layout + ============================================================ */ + +.results-layout { + display: grid; + grid-template-columns: 200px 1fr 240px; + gap: 2rem; + align-items: start; +} + +.results-layout .left-sidebar { + position: sticky; + top: calc(var(--header-height) + 1.5rem); + max-height: calc(100vh - var(--header-height) - 3rem); + overflow-y: auto; +} + +.results-layout .right-sidebar { + position: sticky; + top: calc(var(--header-height) + 1.5rem); + max-height: calc(100vh - var(--header-height) - 3rem); + overflow-y: auto; +} + +.results-layout .results-column { + min-width: 0; +} +``` + +- [ ] **Step 2: Add mobile breakpoints** + +```css +/* Tablet: hide left sidebar, two columns */ +@media (min-width: 769px) and (max-width: 1024px) { + .results-layout { + grid-template-columns: 1fr 220px; + } + .results-layout .left-sidebar { + display: none; + } +} + +/* Mobile: single column, no sidebars */ +@media (max-width: 768px) { + .results-layout { + grid-template-columns: 1fr; + } + .results-layout .left-sidebar, + .results-layout .right-sidebar { + display: none; + } +} +``` + +- [ ] **Step 3: Add preferences page layout CSS** + +```css +/* ============================================================ + Preferences Page Layout + ============================================================ */ + +.preferences-layout { + display: grid; + grid-template-columns: 200px 1fr; + gap: 2rem; + align-items: start; + padding: 2rem 0; +} + +.preferences-nav { + position: sticky; + top: calc(var(--header-height) + 1.5rem); +} + +.preferences-nav-item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.75rem; + border-radius: var(--radius-sm); + color: var(--text-secondary); + text-decoration: none; + font-size: 0.9rem; + transition: background 0.15s, color 0.15s; + cursor: pointer; +} + +.preferences-nav-item:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.preferences-nav-item.active { + background: var(--accent-soft); + color: var(--accent); + font-weight: 500; +} + +.preferences-content { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.5rem; +} + +@media (max-width: 768px) { + .preferences-layout { + grid-template-columns: 1fr; + } + .preferences-nav { + position: static; + display: flex; + overflow-x: auto; + gap: 0.5rem; + padding-bottom: 0.5rem; + } + .preferences-nav-item { + white-space: nowrap; + } +} +``` + +- [ ] **Step 4: Add category tiles CSS** + +```css +/* ============================================================ + Category Tiles + ============================================================ */ + +.category-tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 1rem; + margin-top: 2rem; +} + +.category-tile { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 1rem 0.5rem; + border-radius: var(--radius-md); + text-decoration: none; + color: var(--text-secondary); + font-size: 0.85rem; + transition: background 0.15s, color 0.15s, transform 0.15s, box-shadow 0.15s; +} + +.category-tile:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + transform: translateY(-2px); + box-shadow: var(--shadow-sm); +} + +.category-tile-icon { + font-size: 1.5rem; + line-height: 1; +} + +.category-tile.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +@media (max-width: 768px) { + .category-tiles { + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 0.75rem; + } + .category-tile { + padding: 0.75rem 0.25rem; + font-size: 0.75rem; + } + .category-tile-icon { + font-size: 1.25rem; + } +} +``` + +- [ ] **Step 5: Add left sidebar navigation styles** + +```css +/* ============================================================ + Left Sidebar (Results Page) + ============================================================ */ + +.left-sidebar { + padding: 0; +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.sidebar-nav-title { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + padding: 0.5rem 0.75rem; + margin-top: 0.5rem; +} + +.sidebar-nav-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: var(--radius-sm); + color: var(--text-secondary); + text-decoration: none; + font-size: 0.875rem; + transition: background 0.15s, color 0.15s; +} + +.sidebar-nav-item:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.sidebar-nav-item.active { + background: var(--accent-soft); + color: var(--accent); + font-weight: 500; +} + +.sidebar-nav-item-icon { + font-size: 1rem; + width: 20px; + text-align: center; +} + +.sidebar-filters { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +.sidebar-filter-group { + margin-bottom: 0.75rem; +} + +.sidebar-filter-label { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-muted); + padding: 0 0.75rem; + margin-bottom: 0.25rem; +} + +.sidebar-filter-option { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.75rem; + font-size: 0.8rem; + color: var(--text-secondary); + cursor: pointer; + border-radius: var(--radius-sm); + transition: background 0.15s; +} + +.sidebar-filter-option:hover { + background: var(--bg-tertiary); +} + +.sidebar-filter-option input[type="radio"] { + accent-color: var(--accent); +} + +/* Mobile filter chips */ +.mobile-filter-chips { + display: none; + overflow-x: auto; + gap: 0.5rem; + padding: 0.75rem 0; + -webkit-overflow-scrolling: touch; +} + +.mobile-filter-chips::-webkit-scrollbar { + display: none; +} + +.mobile-filter-chip { + display: inline-flex; + align-items: center; + padding: 0.4rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-full); + font-size: 0.8rem; + color: var(--text-secondary); + white-space: nowrap; + text-decoration: none; + transition: background 0.15s, border-color 0.15s; +} + +.mobile-filter-chip:hover, +.mobile-filter-chip.active { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent); +} + +@media (max-width: 768px) { + .mobile-filter-chips { + display: flex; + } +} +``` + +- [ ] **Step 6: Verify CSS changes compile** + +Run: `go build ./...` +Expected: No errors + +- [ ] **Step 7: Commit** + +```bash +git add internal/views/static/css/kafka.css +git commit -m "feat(frontend): add CSS layout framework for three-column results and preferences page" +``` + +--- + +## PHASE 2: Results Page Three-Column Layout + +### Task 2: Restructure Results Template + +**Files:** +- Modify: `internal/views/templates/results.html` +- Modify: `internal/views/views.go` + +- [ ] **Step 1: Read current results.html to understand exact content** + +Current structure has `.results-layout` grid with `.search-compact` spanning full width, `.results-column`, and `.sidebar`. Need to add left sidebar and restructure grid. + +- [ ] **Step 2: Replace results.html content** + +Replace the entire file content: + +```html +{{define "title"}}{{if .Query}}{{.Query}} — {{end}}{{end}} +{{define "content"}} +
+ + + + +
+ +
+ +
+ + +
+ All + {{range .Categories}} + {{.}} + {{end}} +
+ + + {{template "results_inner" .}} +
+ + + +
+{{end}} +``` + +- [ ] **Step 3: Update PageData struct to include new fields** + +Modify `internal/views/views.go` — add to `PageData` struct: + +```go +type PageData struct { + // ... existing fields ... + + // New fields for three-column layout + Categories []string + CategoryIcons map[string]string + ActiveCategory string + TimeFilters []FilterOption + TypeFilters []FilterOption + ActiveTime string + ActiveType string +} + +// FilterOption represents a filter radio option +type FilterOption struct { + Label string + Value string +} +``` + +- [ ] **Step 4: Update FromResponse to populate new fields** + +In `views.go`, update `FromResponse` to populate the new fields: + +```go +func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageData { + pd := PageData{ + // ... existing initialization ... + + // New: categories with icons + Categories: []string{"all", "news", "images", "videos", "maps", "shopping", "music", "weather"}, + CategoryIcons: map[string]string{ + "all": "🌐", + "news": "📰", + "images": "🖼️", + "videos": "🎬", + "maps": "🗺️", + "shopping": "🛒", + "music": "🎵", + "weather": "🌤️", + }, + ActiveCategory: "all", + + // Time filters + TimeFilters: []FilterOption{ + {Label: "Any time", Value: ""}, + {Label: "Past hour", Value: "h"}, + {Label: "Past 24 hours", Value: "d"}, + {Label: "Past week", Value: "w"}, + {Label: "Past month", Value: "m"}, + {Label: "Past year", Value: "y"}, + }, + ActiveTime: "", + + // Type filters + TypeFilters: []FilterOption{ + {Label: "All results", Value: ""}, + {Label: "News", Value: "news"}, + {Label: "Videos", Value: "video"}, + {Label: "Images", Value: "image"}, + }, + ActiveType: "", + } + // ... rest of function ... +} +``` + +- [ ] **Step 5: Register new preferences template** + +In `views.go`, add to the `init()` function and add `tmplPreferences`: + +```go +var ( + tmplFull *template.Template + tmplIndex *template.Template + tmplFragment *template.Template + tmplPreferences *template.Template +) + +func init() { + tmplFS, _ := fs.Sub(templatesFS, "templates") + + funcMap := template.FuncMap{ + "urlquery": template.URLQueryEscaper, + } + + tmplFull = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, + "base.html", "results.html", "results_inner.html", "result_item.html", "video_item.html", + )) + tmplIndex = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, + "base.html", "index.html", + )) + tmplFragment = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, + "results_inner.html", "result_item.html", "video_item.html", + )) + tmplPreferences = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, + "base.html", "preferences.html", + )) +} +``` + +- [ ] **Step 6: Add RenderPreferences function** + +Add to `views.go`: + +```go +// RenderPreferences renders the full preferences page. +func RenderPreferences(w http.ResponseWriter, sourceURL string) error { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + return tmplPreferences.ExecuteTemplate(w, "base", PageData{ShowHeader: true, SourceURL: sourceURL}) +} +``` + +- [ ] **Step 7: Test compilation** + +Run: `go build ./...` +Expected: No errors + +- [ ] **Step 8: Commit** + +```bash +git add internal/views/views.go internal/views/templates/results.html +git commit -m "feat(frontend): add three-column results layout with left sidebar navigation" +``` + +--- + +## PHASE 3: Homepage Category Tiles + +### Task 3: Add Category Tiles to Homepage + +**Files:** +- Modify: `internal/views/templates/index.html` + +- [ ] **Step 1: Read current index.html** + +- [ ] **Step 2: Replace index.html with tiles** + +```html +{{define "title"}}{{end}} +{{define "content"}} +
+ +

Search the web privately, without tracking or censorship.

+ + + + +
+
+{{end}} +``` + +- [ ] **Step 3: Test compilation** + +Run: `go build ./...` +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add internal/views/templates/index.html +git commit -m "feat(frontend): add category tiles to homepage" +``` + +--- + +## PHASE 4: Preferences Page + +### Task 4: Create Preferences Template + +**Files:** +- Create: `internal/views/templates/preferences.html` + +- [ ] **Step 1: Create preferences.html** + +```html +{{define "title"}}Preferences{{end}} +{{define "content"}} +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+{{end}} +``` + +- [ ] **Step 2: Add preferences section CSS styles** + +Append to `kafka.css`: + +```css +/* ============================================================ + Preferences Page Styles + ============================================================ */ + +.pref-section { + margin-bottom: 2rem; +} + +.pref-section:last-child { + margin-bottom: 0; +} + +.pref-section-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); +} + +.pref-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border); +} + +.pref-row:last-child { + border-bottom: none; +} + +.pref-row label { + font-size: 0.9rem; + color: var(--text-primary); +} + +.pref-row-info { + flex: 1; +} + +.pref-row-info label { + font-weight: 500; +} + +.pref-desc { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 0.25rem; +} + +.pref-row select { + padding: 0.5rem 0.75rem; + font-size: 0.85rem; + font-family: inherit; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text-primary); + cursor: pointer; + min-width: 150px; +} + +.pref-row select:focus { + outline: none; + border-color: var(--accent); +} + +.pref-row input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--accent); + cursor: pointer; + flex-shrink: 0; +} + +.pref-row input[type="checkbox"]:disabled { + opacity: 0.6; + cursor: not-allowed; +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add internal/views/templates/preferences.html internal/views/static/css/kafka.css +git commit -m "feat(frontend): add preferences page template and styles" +``` + +--- + +### Task 5: Add Preferences Route + +**Files:** +- Modify: `internal/httpapi/handlers.go` +- Modify: `cmd/kafka/main.go` + +- [ ] **Step 1: Add GET and POST handlers for /preferences** + +Add to `handlers.go`: + +```go +// Preferences renders the preferences page. +func (h *Handler) Preferences(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/preferences" { + http.NotFound(w, r) + return + } + if err := views.RenderPreferences(w, h.sourceURL); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// PreferencesPOST handles form submission from the preferences page. +func (h *Handler) PreferencesPOST(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/preferences" { + http.NotFound(w, r) + return + } + // Preferences are stored in localStorage on the client. + // This handler exists for form submission completeness but + // the actual save happens via JavaScript. + // Redirect back to preferences page. + http.Redirect(w, r, "/preferences", http.StatusFound) +} +``` + +- [ ] **Step 2: Register the route in main** + +Find where routes are registered (likely in `cmd/kafka/main.go`) and add: + +```go +mux.HandleFunc("GET /preferences", handler.Preferences) +mux.HandleFunc("POST /preferences", handler.PreferencesPOST) +``` + +- [ ] **Step 3: Test compilation** + +Run: `go build ./...` +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add internal/httpapi/handlers.go cmd/kafka/main.go +git commit -m "feat: add GET and POST /preferences route" +``` + +--- + +### Task 6: Update Settings JavaScript + +**Files:** +- Modify: `internal/views/static/js/settings.js` + +- [ ] **Step 1: Reduce popover to theme + engines only** + +Update the `renderPanel` function to remove SafeSearch and Format options. Keep only theme buttons and engine toggles. + +- [ ] **Step 2: Add preferences page navigation JavaScript** + +Add to end of `settings.js`: + +```javascript +// Preferences page navigation +function initPreferences() { + var nav = document.getElementById('preferences-nav'); + if (!nav) return; + + var sections = document.querySelectorAll('.pref-section'); + var navItems = nav.querySelectorAll('.preferences-nav-item'); + + function showSection(id) { + sections.forEach(function(sec) { + sec.style.display = sec.id === 'section-' + id ? 'block' : 'none'; + }); + navItems.forEach(function(item) { + item.classList.toggle('active', item.getAttribute('data-section') === id); + }); + } + + navItems.forEach(function(item) { + item.addEventListener('click', function() { + showSection(item.getAttribute('data-section')); + }); + }); + + // Load saved preferences + var prefs = loadPrefs(); + var themeEl = document.getElementById('pref-theme'); + if (themeEl) themeEl.value = prefs.theme || 'system'; + + var ssEl = document.getElementById('pref-safesearch'); + if (ssEl) ssEl.value = prefs.safeSearch || 'moderate'; + + var fmtEl = document.getElementById('pref-format'); + if (fmtEl) fmtEl.value = prefs.format || 'html'; + + // Save handlers + if (themeEl) { + themeEl.addEventListener('change', function() { + prefs.theme = themeEl.value; + savePrefs(prefs); + applyTheme(prefs.theme); + }); + } + + if (ssEl) { + ssEl.addEventListener('change', function() { + prefs.safeSearch = ssEl.value; + savePrefs(prefs); + }); + } + + if (fmtEl) { + fmtEl.addEventListener('change', function() { + prefs.format = fmtEl.value; + savePrefs(prefs); + }); + } + + // Show first section by default + showSection('search'); +} + +document.addEventListener('DOMContentLoaded', initPreferences); +``` + +- [ ] **Step 3: Test with browser** + +Manual verification needed — cannot test browser JS with `go build` + +- [ ] **Step 4: Commit** + +```bash +git add internal/views/static/js/settings.js +git commit -m "feat(frontend): reduce popover to theme+engines, add preferences page JS" +``` + +--- + +## PHASE 5: Polish and Mobile Responsiveness + +### Task 7: Mobile Filter Chips Integration + +**Files:** +- Modify: `internal/views/templates/results.html` + +- [ ] **Step 1: Ensure mobile filter chips have working category links** + +The current results.html has mobile filter chips with category links. These should preserve existing query params for pagination/HTMX navigation. + +- [ ] **Step 2: Add filter form submission via HTMX** + +Update the filter radio buttons to submit via HTMX when changed. + +- [ ] **Step 3: Commit** + +```bash +git add internal/views/templates/results.html +git commit -m "fix(frontend): add HTMX filter submission" +``` + +--- + +### Task 8: Final Mobile Responsiveness Audit + +**Files:** +- Review: `internal/views/static/css/kafka.css` + +- [ ] **Step 1: Test all breakpoints manually** + +- [ ] **Step 2: Fix any layout issues found** + +- [ ] **Step 3: Commit any fixes** + +```bash +git add internal/views/static/css/kafka.css +git commit -m "fix(frontend): improve mobile responsiveness" +``` + +--- + +## Summary + +| Phase | Task | Files | +|-------|------|-------| +| 1 | CSS Layout Framework | `kafka.css` | +| 2 | Results Three-Column | `results.html`, `views.go` | +| 3 | Homepage Tiles | `index.html` | +| 4 | Preferences Page | `preferences.html` (new), `handlers.go`, `settings.js` | +| 5 | Polish | Various | + +**Total: 8 tasks across 5 phases** + +Run `go test ./...` after each phase to verify nothing is broken. From 19f5c89053518d2243ec7a022543a1d33cd7cc62 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 12:18:38 +0000 Subject: [PATCH 020/100] fix: upgrade x/net to v0.38.0 (resolves Dependabot XSS alert) --- go.mod | 3 +++ 1 file changed, 3 insertions(+) diff --git a/go.mod b/go.mod index 6981818..e562df5 100644 --- a/go.mod +++ b/go.mod @@ -15,3 +15,6 @@ require ( go.uber.org/atomic v1.11.0 // indirect golang.org/x/net v0.33.0 // indirect ) + +replace golang.org/x/net => golang.org/x/net v0.38.0 +) From 8909654c8fa69805072dfd994d7f441e84cbd872 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:21:31 +0100 Subject: [PATCH 021/100] docs: fix implementation plan issues from review - Move template registration from Phase 2 to Phase 4 (was causing build failure) - Add filter params (activeCategory, activeTime, activeType) to FromResponse - Add DisabledCategories to PageData for backend-unsupported categories - Add disabled class to sidebar for future categories - Clarify POST handler is a no-op for localStorage-only preferences - Note CSS must be tested manually in browser Co-Authored-By: Claude Opus 4.6 --- ...26-03-22-brave-search-frontend-redesign.md | 99 +++++++++---------- 1 file changed, 44 insertions(+), 55 deletions(-) diff --git a/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md b/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md index 486cee4..a7f1b62 100644 --- a/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md +++ b/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md @@ -354,11 +354,13 @@ Append to end of `kafka.css`, before the `@media print` block: } ``` -- [ ] **Step 6: Verify CSS changes compile** +- [ ] **Step 6: Verify Go compilation** Run: `go build ./...` Expected: No errors +Note: CSS is embedded as static files and not processed by the Go compiler. CSS changes must be tested manually in a browser. + - [ ] **Step 7: Commit** ```bash @@ -501,17 +503,28 @@ type FilterOption struct { } ``` -- [ ] **Step 4: Update FromResponse to populate new fields** +- [ ] **Step 4: Update FromResponse to accept filter params** -In `views.go`, update `FromResponse` to populate the new fields: +Update `FromResponse` signature to accept `activeCategory`, `activeTime`, and `activeType` from the request. First update the `Search` handler in `handlers.go` to pass these params: ```go -func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageData { +// In handlers.go, update Search handler: +pd := views.FromResponse(resp, req.Query, req.Pageno) +pd.ActiveCategory = r.FormValue("category") +pd.ActiveTime = r.FormValue("time") +pd.ActiveType = r.FormValue("type") +``` + +Then update `FromResponse` in `views.go` to accept these params: + +```go +func FromResponse(resp contracts.SearchResponse, query string, pageno int, activeCategory, activeTime, activeType string) PageData { pd := PageData{ // ... existing initialization ... // New: categories with icons - Categories: []string{"all", "news", "images", "videos", "maps", "shopping", "music", "weather"}, + Categories: []string{"all", "news", "images", "videos", "maps"}, + DisabledCategories: []string{"shopping", "music", "weather"}, CategoryIcons: map[string]string{ "all": "🌐", "news": "📰", @@ -522,7 +535,8 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageD "music": "🎵", "weather": "🌤️", }, - ActiveCategory: "all", + ActiveCategory: activeCategory, + if activeCategory == "" { activeCategory = "all" } // Time filters TimeFilters: []FilterOption{ @@ -533,7 +547,7 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageD {Label: "Past month", Value: "m"}, {Label: "Past year", Value: "y"}, }, - ActiveTime: "", + ActiveTime: activeTime, // Type filters TypeFilters: []FilterOption{ @@ -542,64 +556,40 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageD {Label: "Videos", Value: "video"}, {Label: "Images", Value: "image"}, }, - ActiveType: "", + ActiveType: activeType, } // ... rest of function ... } ``` -- [ ] **Step 5: Register new preferences template** +Add `DisabledCategories []string` field to `PageData`. -In `views.go`, add to the `init()` function and add `tmplPreferences`: +- [ ] **Step 5: Update results.html sidebar to show disabled state** -```go -var ( - tmplFull *template.Template - tmplIndex *template.Template - tmplFragment *template.Template - tmplPreferences *template.Template -) +Update the sidebar category loop to conditionally apply `disabled` class: -func init() { - tmplFS, _ := fs.Sub(templatesFS, "templates") - - funcMap := template.FuncMap{ - "urlquery": template.URLQueryEscaper, - } - - tmplFull = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, - "base.html", "results.html", "results_inner.html", "result_item.html", "video_item.html", - )) - tmplIndex = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, - "base.html", "index.html", - )) - tmplFragment = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, - "results_inner.html", "result_item.html", "video_item.html", - )) - tmplPreferences = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, - "base.html", "preferences.html", - )) -} +```html +{{range .Categories}} + + {{index $.CategoryIcons .}} + {{.}} + +{{end}} + +{{range .DisabledCategories}} + + {{index $.CategoryIcons .}} + {{.}} + +{{end}} ``` -- [ ] **Step 6: Add RenderPreferences function** - -Add to `views.go`: - -```go -// RenderPreferences renders the full preferences page. -func RenderPreferences(w http.ResponseWriter, sourceURL string) error { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - return tmplPreferences.ExecuteTemplate(w, "base", PageData{ShowHeader: true, SourceURL: sourceURL}) -} -``` - -- [ ] **Step 7: Test compilation** +- [ ] **Step 6: Test compilation** Run: `go build ./...` Expected: No errors -- [ ] **Step 8: Commit** +- [ ] **Step 7: Commit** ```bash git add internal/views/views.go internal/views/templates/results.html @@ -1020,15 +1010,14 @@ func (h *Handler) Preferences(w http.ResponseWriter, r *http.Request) { } // PreferencesPOST handles form submission from the preferences page. +// NOTE: This is a no-op. All preferences are stored in localStorage on the client +// via JavaScript. This handler exists only for form submission completeness (e.g., +// if a form POSTs without JS). The JavaScript in settings.js handles all saves. func (h *Handler) PreferencesPOST(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/preferences" { http.NotFound(w, r) return } - // Preferences are stored in localStorage on the client. - // This handler exists for form submission completeness but - // the actual save happens via JavaScript. - // Redirect back to preferences page. http.Redirect(w, r, "/preferences", http.StatusFound) } ``` From d21e9189b8217ebfcbc1c2a3d23bfd1fe3df37ac Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:22:52 +0100 Subject: [PATCH 022/100] fix(engines): validate Wikipedia language codes to prevent SSRF Wikipedia language subdomain was derived from user input without validation, allowing attackers to redirect requests via malicious language values like "evil.com.attacker.com". Added a whitelist of valid Wikipedia language codes to prevent this. Co-Authored-By: Claude Opus 4.6 --- internal/engines/wikipedia.go | 43 +++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/internal/engines/wikipedia.go b/internal/engines/wikipedia.go index bbf1222..f29ff74 100644 --- a/internal/engines/wikipedia.go +++ b/internal/engines/wikipedia.go @@ -33,6 +33,44 @@ type WikipediaEngine struct { client *http.Client } +// validWikipediaLangs contains the set of valid Wikipedia language codes. +// This prevents SSRF attacks where an attacker could use a malicious language +// value to redirect requests to an attacker-controlled domain. +var validWikipediaLangs = map[string]struct{}{ + "aa": {}, "ab": {}, "ae": {}, "af": {}, "ak": {}, "am": {}, "an": {}, + "ar": {}, "arc": {}, "as": {}, "ast": {}, "at": {}, "av": {}, "ay": {}, + "az": {}, "ba": {}, "be": {}, "bg": {}, "bh": {}, "bi": {}, "bm": {}, + "bn": {}, "bo": {}, "br": {}, "bs": {}, "ca": {}, "ce": {}, "ch": {}, + "co": {}, "cr": {}, "cs": {}, "cu": {}, "cv": {}, "cy": {}, "da": {}, + "de": {}, "di": {}, "dv": {}, "dz": {}, "ee": {}, "el": {}, "en": {}, + "eo": {}, "es": {}, "et": {}, "eu": {}, "fa": {}, "ff": {}, "fi": {}, + "fj": {}, "fo": {}, "fr": {}, "fy": {}, "ga": {}, "gd": {}, "gl": {}, + "gn": {}, "gu": {}, "gv": {}, "ha": {}, "he": {}, "hi": {}, "ho": {}, + "hr": {}, "ht": {}, "hu": {}, "hy": {}, "hz": {}, "ia": {}, "id": {}, + "ie": {}, "ig": {}, "ii": {}, "ik": {}, "io": {}, "is": {}, "it": {}, + "iu": {}, "ja": {}, "jv": {}, "ka": {}, "kg": {}, "ki": {}, "kj": {}, + "kk": {}, "kl": {}, "km": {}, "kn": {}, "ko": {}, "kr": {}, "ks": {}, + "ku": {}, "kv": {}, "kw": {}, "ky": {}, "la": {}, "lb": {}, "lg": {}, + "li": {}, "lij": {}, "ln": {}, "lo": {}, "lt": {}, "lv": {}, "mg": {}, + "mh": {}, "mi": {}, "mk": {}, "ml": {}, "mn": {}, "mo": {}, "mr": {}, + "ms": {}, "mt": {}, "mus": {}, "my": {}, "na": {}, "nah": {}, "nap": {}, + "nd": {}, "nds": {}, "ne": {}, "new": {}, "ng": {}, "nl": {}, "nn": {}, + "no": {}, "nov": {}, "nrm": {}, "nv": {}, "ny": {}, "oc": {}, "oj": {}, + "om": {}, "or": {}, "os": {}, "pa": {}, "pag": {}, "pam": {}, "pap": {}, + "pdc": {}, "pl": {}, "pms": {}, "pn": {}, "ps": {}, "pt": {}, "qu": {}, + "rm": {}, "rmy": {}, "rn": {}, "ro": {}, "roa-rup": {}, "ru": {}, + "rw": {}, "sa": {}, "sah": {}, "sc": {}, "scn": {}, "sco": {}, "sd": {}, + "se": {}, "sg": {}, "sh": {}, "si": {}, "simple": {}, "sk": {}, "sl": {}, + "sm": {}, "sn": {}, "so": {}, "sq": {}, "sr": {}, "ss": {}, "st": {}, + "su": {}, "sv": {}, "sw": {}, "szl": {}, "ta": {}, "te": {}, "tg": {}, + "th": {}, "ti": {}, "tk": {}, "tl": {}, "tn": {}, "to": {}, "tpi": {}, + "tr": {}, "ts": {}, "tt": {}, "tum": {}, "tw": {}, "ty": {}, "udm": {}, + "ug": {}, "uk": {}, "ur": {}, "uz": {}, "ve": {}, "vec": {}, "vi": {}, + "vls": {}, "vo": {}, "wa": {}, "wo": {}, "xal": {}, "xh": {}, "yi": {}, + "yo": {}, "za": {}, "zea": {}, "zh": {}, "zh-classical": {}, + "zh-min-nan": {}, "zh-yue": {}, "zu": {}, +} + func (e *WikipediaEngine) Name() string { return "wikipedia" } func (e *WikipediaEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) { @@ -50,6 +88,11 @@ func (e *WikipediaEngine) Search(ctx context.Context, req contracts.SearchReques // Wikipedia subdomains are based on the language code; keep it simple for MVP. lang = strings.SplitN(lang, "-", 2)[0] lang = strings.ReplaceAll(lang, "_", "-") + // Validate lang against whitelist to prevent SSRF attacks where an attacker + // could use a malicious language value to redirect requests to their server. + if _, ok := validWikipediaLangs[lang]; !ok { + lang = "en" + } wikiNetloc := fmt.Sprintf("%s.wikipedia.org", lang) endpoint := fmt.Sprintf( From 2fae98a336d534e885362d0ec4ec57749fd02d0d Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:23:10 +0100 Subject: [PATCH 023/100] fix(go): remove stray parenthesis from go.mod Co-Authored-By: Claude Opus 4.6 --- go.mod | 1 - 1 file changed, 1 deletion(-) diff --git a/go.mod b/go.mod index e562df5..67a0956 100644 --- a/go.mod +++ b/go.mod @@ -17,4 +17,3 @@ require ( ) replace golang.org/x/net => golang.org/x/net v0.38.0 -) From 7bc68db70c06d0dfa3101851f3cd5dbdc4c4aa72 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:23:37 +0100 Subject: [PATCH 024/100] chore(deps): update go.sum after go mod tidy Co-Authored-By: Claude Opus 4.6 --- go.sum | 45 +++++++-------------------------------------- 1 file changed, 7 insertions(+), 38 deletions(-) diff --git a/go.sum b/go.sum index 5700b4c..65bdc02 100644 --- a/go.sum +++ b/go.sum @@ -28,67 +28,36 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From ce92a692f8eb54c83d4f3824a44bbe1da578aa4b Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:24:11 +0100 Subject: [PATCH 025/100] docs: fix Go syntax errors in implementation plan - Move if statement outside struct literal in FromResponse - Define FilterOption at package level (not inside function) - Add DisabledCategories to PageData struct - Add defaults handling before struct literal - Update Search handler call with filter params Co-Authored-By: Claude Opus 4.6 --- ...26-03-22-brave-search-frontend-redesign.md | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md b/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md index a7f1b62..8ff8485 100644 --- a/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md +++ b/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md @@ -478,49 +478,49 @@ Replace the entire file content: {{end}} ``` -- [ ] **Step 3: Update PageData struct to include new fields** +- [ ] **Step 3: Add FilterOption struct and update PageData struct** -Modify `internal/views/views.go` — add to `PageData` struct: +Add `FilterOption` struct at package level in `views.go` (near `PageNumber` struct): ```go -type PageData struct { - // ... existing fields ... - - // New fields for three-column layout - Categories []string - CategoryIcons map[string]string - ActiveCategory string - TimeFilters []FilterOption - TypeFilters []FilterOption - ActiveTime string - ActiveType string -} - -// FilterOption represents a filter radio option +// FilterOption represents a filter radio option for the sidebar. type FilterOption struct { Label string Value string } ``` -- [ ] **Step 4: Update FromResponse to accept filter params** - -Update `FromResponse` signature to accept `activeCategory`, `activeTime`, and `activeType` from the request. First update the `Search` handler in `handlers.go` to pass these params: +Then update `PageData` struct to include new fields: ```go -// In handlers.go, update Search handler: -pd := views.FromResponse(resp, req.Query, req.Pageno) -pd.ActiveCategory = r.FormValue("category") -pd.ActiveTime = r.FormValue("time") -pd.ActiveType = r.FormValue("type") +type PageData struct { + // ... existing fields (SourceURL, Query, Pageno, etc.) ... + + // New fields for three-column layout + Categories []string + CategoryIcons map[string]string + DisabledCategories []string + ActiveCategory string + TimeFilters []FilterOption + TypeFilters []FilterOption + ActiveTime string + ActiveType string +} ``` -Then update `FromResponse` in `views.go` to accept these params: +- [ ] **Step 4: Update FromResponse signature and body** + +Update `FromResponse` signature to accept filter params and set defaults: ```go func FromResponse(resp contracts.SearchResponse, query string, pageno int, activeCategory, activeTime, activeType string) PageData { + // Set defaults + if activeCategory == "" { + activeCategory = "all" + } + pd := PageData{ - // ... existing initialization ... + // ... existing initialization (NumberOfResults, Results, etc.) ... // New: categories with icons Categories: []string{"all", "news", "images", "videos", "maps"}, @@ -536,7 +536,6 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int, activ "weather": "🌤️", }, ActiveCategory: activeCategory, - if activeCategory == "" { activeCategory = "all" } // Time filters TimeFilters: []FilterOption{ @@ -562,7 +561,12 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int, activ } ``` -Add `DisabledCategories []string` field to `PageData`. +Update the `Search` handler in `handlers.go` to pass filter params: + +```go +pd := views.FromResponse(resp, req.Query, req.Pageno, + r.FormValue("category"), r.FormValue("time"), r.FormValue("type")) +``` - [ ] **Step 5: Update results.html sidebar to show disabled state** From d071921329ae729116a89db04410f683d1ef40e0 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:26:22 +0100 Subject: [PATCH 026/100] docs: add missing template registration step to plan - Add tmplPreferences variable to views.go var block - Initialize tmplPreferences in init() function - Add RenderPreferences function to views.go - Fix step numbering for Task 4 Co-Authored-By: Claude Opus 4.6 --- ...26-03-22-brave-search-frontend-redesign.md | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md b/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md index 8ff8485..28b98a1 100644 --- a/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md +++ b/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md @@ -982,10 +982,35 @@ Append to `kafka.css`: } ``` -- [ ] **Step 3: Commit** +- [ ] **Step 3: Register preferences template in views.go** + +Add `tmplPreferences` variable and initialize it in `init()`. Also add `RenderPreferences` function: + +```go +// In views.go, add to var block: +var ( + tmplFull *template.Template + tmplIndex *template.Template + tmplFragment *template.Template + tmplPreferences *template.Template +) + +// In init(), after existing template parsing, add: +tmplPreferences = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, + "base.html", "preferences.html", +)) + +// Add RenderPreferences function: +func RenderPreferences(w http.ResponseWriter, sourceURL string) error { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + return tmplPreferences.ExecuteTemplate(w, "base", PageData{ShowHeader: true, SourceURL: sourceURL}) +} +``` + +- [ ] **Step 4: Commit** ```bash -git add internal/views/templates/preferences.html internal/views/static/css/kafka.css +git add internal/views/templates/preferences.html internal/views/static/css/kafka.css internal/views/views.go git commit -m "feat(frontend): add preferences page template and styles" ``` From 0af49f91b75d3e31b8efd60bb8f47df0a489d61f Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:29:39 +0100 Subject: [PATCH 027/100] feat(frontend): add CSS layout framework for three-column results and preferences page Co-Authored-By: Claude Opus 4.6 --- internal/views/static/css/kafka.css | 300 ++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) diff --git a/internal/views/static/css/kafka.css b/internal/views/static/css/kafka.css index 8ae97ea..d23ea7a 100644 --- a/internal/views/static/css/kafka.css +++ b/internal/views/static/css/kafka.css @@ -975,6 +975,306 @@ a:focus-visible { background: var(--border-focus); } +/* ============================================================ + Three-Column Results Layout + ============================================================ */ + +.results-layout { + display: grid; + grid-template-columns: 200px 1fr 240px; + gap: 2rem; + align-items: start; +} + +.results-layout .left-sidebar { + position: sticky; + top: calc(var(--header-height) + 1.5rem); + max-height: calc(100vh - var(--header-height) - 3rem); + overflow-y: auto; +} + +.results-layout .right-sidebar { + position: sticky; + top: calc(var(--header-height) + 1.5rem); + max-height: calc(100vh - var(--header-height) - 3rem); + overflow-y: auto; +} + +.results-layout .results-column { + min-width: 0; +} + +/* Tablet: hide left sidebar, two columns */ +@media (min-width: 769px) and (max-width: 1024px) { + .results-layout { + grid-template-columns: 1fr 220px; + } + .results-layout .left-sidebar { + display: none; + } +} + +/* Mobile: single column, no sidebars */ +@media (max-width: 768px) { + .results-layout { + grid-template-columns: 1fr; + } + .results-layout .left-sidebar, + .results-layout .right-sidebar { + display: none; + } +} + +/* ============================================================ + Preferences Page Layout + ============================================================ */ + +.preferences-layout { + display: grid; + grid-template-columns: 200px 1fr; + gap: 2rem; + align-items: start; + padding: 2rem 0; +} + +.preferences-nav { + position: sticky; + top: calc(var(--header-height) + 1.5rem); +} + +.preferences-nav-item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.75rem; + border-radius: var(--radius-sm); + color: var(--text-secondary); + text-decoration: none; + font-size: 0.9rem; + transition: background 0.15s, color 0.15s; + cursor: pointer; +} + +.preferences-nav-item:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.preferences-nav-item.active { + background: var(--accent-soft); + color: var(--accent); + font-weight: 500; +} + +.preferences-content { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.5rem; +} + +@media (max-width: 768px) { + .preferences-layout { + grid-template-columns: 1fr; + } + .preferences-nav { + position: static; + display: flex; + overflow-x: auto; + gap: 0.5rem; + padding-bottom: 0.5rem; + } + .preferences-nav-item { + white-space: nowrap; + } +} + +/* ============================================================ + Category Tiles + ============================================================ */ + +.category-tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 1rem; + margin-top: 2rem; +} + +.category-tile { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 1rem 0.5rem; + border-radius: var(--radius-md); + text-decoration: none; + color: var(--text-secondary); + font-size: 0.85rem; + transition: background 0.15s, color 0.15s, transform 0.15s, box-shadow 0.15s; +} + +.category-tile:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + transform: translateY(-2px); + box-shadow: var(--shadow-sm); +} + +.category-tile-icon { + font-size: 1.5rem; + line-height: 1; +} + +.category-tile.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +@media (max-width: 768px) { + .category-tiles { + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 0.75rem; + } + .category-tile { + padding: 0.75rem 0.25rem; + font-size: 0.75rem; + } + .category-tile-icon { + font-size: 1.25rem; + } +} + +/* ============================================================ + Left Sidebar (Results Page) + ============================================================ */ + +.left-sidebar { + padding: 0; +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.sidebar-nav-title { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + padding: 0.5rem 0.75rem; + margin-top: 0.5rem; +} + +.sidebar-nav-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: var(--radius-sm); + color: var(--text-secondary); + text-decoration: none; + font-size: 0.875rem; + transition: background 0.15s, color 0.15s; +} + +.sidebar-nav-item:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.sidebar-nav-item.active { + background: var(--accent-soft); + color: var(--accent); + font-weight: 500; +} + +.sidebar-nav-item-icon { + font-size: 1rem; + width: 20px; + text-align: center; +} + +.sidebar-filters { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +.sidebar-filter-group { + margin-bottom: 0.75rem; +} + +.sidebar-filter-label { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-muted); + padding: 0 0.75rem; + margin-bottom: 0.25rem; +} + +.sidebar-filter-option { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.75rem; + font-size: 0.8rem; + color: var(--text-secondary); + cursor: pointer; + border-radius: var(--radius-sm); + transition: background 0.15s; +} + +.sidebar-filter-option:hover { + background: var(--bg-tertiary); +} + +.sidebar-filter-option input[type="radio"] { + accent-color: var(--accent); +} + +/* Mobile filter chips */ +.mobile-filter-chips { + display: none; + overflow-x: auto; + gap: 0.5rem; + padding: 0.75rem 0; + -webkit-overflow-scrolling: touch; +} + +.mobile-filter-chips::-webkit-scrollbar { + display: none; +} + +.mobile-filter-chip { + display: inline-flex; + align-items: center; + padding: 0.4rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-full); + font-size: 0.8rem; + color: var(--text-secondary); + white-space: nowrap; + text-decoration: none; + transition: background 0.15s, border-color 0.15s; +} + +.mobile-filter-chip:hover, +.mobile-filter-chip.active { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent); +} + +@media (max-width: 768px) { + .mobile-filter-chips { + display: flex; + } +} + /* ============================================================ Print ============================================================ */ From 2e7075adf1ad01be8ad29c1d5831fe3b132a78e2 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:33:24 +0100 Subject: [PATCH 028/100] fix(frontend): merge duplicate sidebar sticky rules --- internal/views/static/css/kafka.css | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/internal/views/static/css/kafka.css b/internal/views/static/css/kafka.css index d23ea7a..9f014e0 100644 --- a/internal/views/static/css/kafka.css +++ b/internal/views/static/css/kafka.css @@ -986,13 +986,7 @@ a:focus-visible { align-items: start; } -.results-layout .left-sidebar { - position: sticky; - top: calc(var(--header-height) + 1.5rem); - max-height: calc(100vh - var(--header-height) - 3rem); - overflow-y: auto; -} - +.results-layout .left-sidebar, .results-layout .right-sidebar { position: sticky; top: calc(var(--header-height) + 1.5rem); From 0e79b729fee5df61d185acfb69c1ef5ee45bc6e9 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:36:09 +0100 Subject: [PATCH 029/100] feat(frontend): add three-column results layout with left sidebar navigation Co-Authored-By: Claude Opus 4.6 --- internal/httpapi/handlers.go | 3 +- internal/views/templates/results.html | 85 ++++++++++++++++++++------- internal/views/views.go | 57 +++++++++++++++++- 3 files changed, 122 insertions(+), 23 deletions(-) diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index f8db054..e27db01 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -112,7 +112,8 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { } if req.Format == contracts.FormatHTML { - pd := views.FromResponse(resp, req.Query, req.Pageno) + pd := views.FromResponse(resp, req.Query, req.Pageno, + r.FormValue("category"), r.FormValue("time"), r.FormValue("type")) if err := views.RenderSearchAuto(w, r, pd); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } diff --git a/internal/views/templates/results.html b/internal/views/templates/results.html index 7010a3a..39e7c64 100644 --- a/internal/views/templates/results.html +++ b/internal/views/templates/results.html @@ -1,32 +1,75 @@ {{define "title"}}{{if .Query}}{{.Query}} — {{end}}{{end}} {{define "content"}}
- -
- -
+ + + + +
+ +
+ +
+ + +
+ All + {{range .Categories}} + {{.}} + {{end}} +
+ + {{template "results_inner" .}}
- -
-{{end}} +{{end}} \ No newline at end of file diff --git a/internal/views/views.go b/internal/views/views.go index 4d7289c..0161d2b 100644 --- a/internal/views/views.go +++ b/internal/views/views.go @@ -50,6 +50,15 @@ type PageData struct { UnresponsiveEngines [][2]string PageNumbers []PageNumber ShowHeader bool + // New fields for three-column layout + Categories []string + CategoryIcons map[string]string + DisabledCategories []string + ActiveCategory string + TimeFilters []FilterOption + TypeFilters []FilterOption + ActiveTime string + ActiveType string } // ResultView is a template-friendly wrapper around a MainResult. @@ -73,6 +82,12 @@ type InfoboxView struct { ImgSrc string } +// FilterOption represents a filter radio option for the sidebar. +type FilterOption struct { + Label string + Value string +} + var ( tmplFull *template.Template tmplIndex *template.Template @@ -116,12 +131,52 @@ func OpenSearchXML(baseURL string) ([]byte, error) { } // FromResponse builds PageData from a search response and request params. -func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageData { +func FromResponse(resp contracts.SearchResponse, query string, pageno int, activeCategory, activeTime, activeType string) PageData { + // Set defaults + if activeCategory == "" { + activeCategory = "all" + } + pd := PageData{ Query: query, Pageno: pageno, NumberOfResults: resp.NumberOfResults, UnresponsiveEngines: resp.UnresponsiveEngines, + + // New: categories with icons + Categories: []string{"all", "news", "images", "videos", "maps"}, + DisabledCategories: []string{"shopping", "music", "weather"}, + CategoryIcons: map[string]string{ + "all": "🌐", + "news": "📰", + "images": "🖼️", + "videos": "🎬", + "maps": "🗺️", + "shopping": "🛒", + "music": "🎵", + "weather": "🌤️", + }, + ActiveCategory: activeCategory, + + // Time filters + TimeFilters: []FilterOption{ + {Label: "Any time", Value: ""}, + {Label: "Past hour", Value: "h"}, + {Label: "Past 24 hours", Value: "d"}, + {Label: "Past week", Value: "w"}, + {Label: "Past month", Value: "m"}, + {Label: "Past year", Value: "y"}, + }, + ActiveTime: activeTime, + + // Type filters + TypeFilters: []FilterOption{ + {Label: "All results", Value: ""}, + {Label: "News", Value: "news"}, + {Label: "Videos", Value: "video"}, + {Label: "Images", Value: "image"}, + }, + ActiveType: activeType, } // Convert results. From bfcbd45c572a8db13ffedc2c95e0a0e9f42cc935 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:40:16 +0100 Subject: [PATCH 030/100] fix(frontend): update FromResponse tests and fix disabled categories rendering Co-Authored-By: Claude Opus 4.6 --- internal/views/templates/results.html | 7 +++++++ internal/views/views.go | 1 - internal/views/views_test.go | 6 +++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/views/templates/results.html b/internal/views/templates/results.html index 39e7c64..59bc525 100644 --- a/internal/views/templates/results.html +++ b/internal/views/templates/results.html @@ -11,6 +11,13 @@ {{.}} {{end}} + + {{range .DisabledCategories}} + + {{index $.CategoryIcons .}} + {{.}} + + {{end}} + + +
-{{end}} +{{end}} \ No newline at end of file From b4053b7f9894aba0ee363d6bbfc489d8a1734795 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:47:30 +0100 Subject: [PATCH 032/100] feat(frontend): add preferences page template and styles Co-Authored-By: Claude Opus 4.6 --- internal/views/static/css/kafka.css | 83 ++++++++++ internal/views/templates/preferences.html | 191 ++++++++++++++++++++++ internal/views/views.go | 10 ++ 3 files changed, 284 insertions(+) create mode 100644 internal/views/templates/preferences.html diff --git a/internal/views/static/css/kafka.css b/internal/views/static/css/kafka.css index 9f014e0..ad094e9 100644 --- a/internal/views/static/css/kafka.css +++ b/internal/views/static/css/kafka.css @@ -1269,6 +1269,89 @@ a:focus-visible { } } +/* ============================================================ + Preferences Page Styles + ============================================================ */ + +.pref-section { + margin-bottom: 2rem; +} + +.pref-section:last-child { + margin-bottom: 0; +} + +.pref-section-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); +} + +.pref-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border); +} + +.pref-row:last-child { + border-bottom: none; +} + +.pref-row label { + font-size: 0.9rem; + color: var(--text-primary); +} + +.pref-row-info { + flex: 1; +} + +.pref-row-info label { + font-weight: 500; +} + +.pref-desc { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 0.25rem; +} + +.pref-row select { + padding: 0.5rem 0.75rem; + font-size: 0.85rem; + font-family: inherit; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text-primary); + cursor: pointer; + min-width: 150px; +} + +.pref-row select:focus { + outline: none; + border-color: var(--accent); +} + +.pref-row input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--accent); + cursor: pointer; + flex-shrink: 0; +} + +.pref-row input[type="checkbox"]:disabled { + opacity: 0.6; + cursor: not-allowed; +} + /* ============================================================ Print ============================================================ */ diff --git a/internal/views/templates/preferences.html b/internal/views/templates/preferences.html new file mode 100644 index 0000000..394c27f --- /dev/null +++ b/internal/views/templates/preferences.html @@ -0,0 +1,191 @@ +{{define "title"}}Preferences{{end}} +{{define "content"}} +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+{{end}} \ No newline at end of file diff --git a/internal/views/views.go b/internal/views/views.go index ac6d4b0..2dec5a7 100644 --- a/internal/views/views.go +++ b/internal/views/views.go @@ -92,6 +92,7 @@ var ( tmplFull *template.Template tmplIndex *template.Template tmplFragment *template.Template + tmplPreferences *template.Template ) func init() { @@ -111,6 +112,9 @@ func init() { tmplFragment = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, "results_inner.html", "result_item.html", "video_item.html", )) + tmplPreferences = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, + "base.html", "preferences.html", + )) } // StaticFS returns the embedded static file system for serving CSS/JS/images. @@ -288,3 +292,9 @@ func RenderSearchAuto(w http.ResponseWriter, r *http.Request, data PageData) err return RenderSearch(w, data) } +// RenderPreferences renders the full preferences page. +func RenderPreferences(w http.ResponseWriter, sourceURL string) error { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + return tmplPreferences.ExecuteTemplate(w, "base", PageData{ShowHeader: true, SourceURL: sourceURL}) +} + From 70818558cd0bb25458e97c6f4d2b84a5a052ce23 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:53:23 +0100 Subject: [PATCH 033/100] feat: add GET and POST /preferences route Co-Authored-By: Claude Opus 4.6 --- cmd/kafka/main.go | 2 ++ internal/httpapi/handlers.go | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/cmd/kafka/main.go b/cmd/kafka/main.go index cdc81b5..6785ba7 100644 --- a/cmd/kafka/main.go +++ b/cmd/kafka/main.go @@ -84,6 +84,8 @@ func main() { mux.HandleFunc("/healthz", h.Healthz) mux.HandleFunc("/search", h.Search) mux.HandleFunc("/autocompleter", h.Autocompleter) + mux.HandleFunc("/preferences", h.Preferences) + mux.HandleFunc("POST /preferences", h.PreferencesPOST) mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL)) // Serve embedded static files (CSS, JS, images). diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index e27db01..46df1d9 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -142,3 +142,26 @@ func (h *Handler) Autocompleter(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(suggestions) } + +// Preferences renders the preferences page. +func (h *Handler) Preferences(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/preferences" { + http.NotFound(w, r) + return + } + if err := views.RenderPreferences(w, h.sourceURL); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// PreferencesPOST handles form submission from the preferences page. +// NOTE: This is a no-op. All preferences are stored in localStorage on the client +// via JavaScript. This handler exists only for form submission completeness (e.g., +// if a form POSTs without JS). The JavaScript in settings.js handles all saves. +func (h *Handler) PreferencesPOST(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/preferences" { + http.NotFound(w, r) + return + } + http.Redirect(w, r, "/preferences", http.StatusFound) +} From 0afcf509c31ebdb95fdff122d3866344b6613adc Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:57:32 +0100 Subject: [PATCH 034/100] fix: use single Preferences handler with method check instead of dead POST route --- cmd/kafka/main.go | 1 - internal/httpapi/handlers.go | 20 +++++++------------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/cmd/kafka/main.go b/cmd/kafka/main.go index 6785ba7..3a0a80e 100644 --- a/cmd/kafka/main.go +++ b/cmd/kafka/main.go @@ -85,7 +85,6 @@ func main() { mux.HandleFunc("/search", h.Search) mux.HandleFunc("/autocompleter", h.Autocompleter) mux.HandleFunc("/preferences", h.Preferences) - mux.HandleFunc("POST /preferences", h.PreferencesPOST) mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL)) // Serve embedded static files (CSS, JS, images). diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index 46df1d9..ce4165b 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -143,25 +143,19 @@ func (h *Handler) Autocompleter(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(suggestions) } -// Preferences renders the preferences page. +// Preferences handles GET and POST for the preferences page. func (h *Handler) Preferences(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/preferences" { http.NotFound(w, r) return } + if r.Method == "POST" { + // Preferences are stored in localStorage on the client via JavaScript. + // This handler exists only for form submission completeness. + http.Redirect(w, r, "/preferences", http.StatusFound) + return + } if err := views.RenderPreferences(w, h.sourceURL); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } - -// PreferencesPOST handles form submission from the preferences page. -// NOTE: This is a no-op. All preferences are stored in localStorage on the client -// via JavaScript. This handler exists only for form submission completeness (e.g., -// if a form POSTs without JS). The JavaScript in settings.js handles all saves. -func (h *Handler) PreferencesPOST(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/preferences" { - http.NotFound(w, r) - return - } - http.Redirect(w, r, "/preferences", http.StatusFound) -} From 6d7e68ada17a655ef417097c71c32baec08b095a Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 14:00:53 +0100 Subject: [PATCH 035/100] feat(frontend): reduce popover to theme+engines, add preferences page JS --- internal/views/static/js/settings.js | 104 ++++++++++++++++----------- 1 file changed, 64 insertions(+), 40 deletions(-) diff --git a/internal/views/static/js/settings.js b/internal/views/static/js/settings.js index 762fbcb..9682e6a 100644 --- a/internal/views/static/js/settings.js +++ b/internal/views/static/js/settings.js @@ -103,28 +103,7 @@ function renderPanel(prefs) { engineToggles += ''; }); - 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 += ''; - }); - fmtOptions.forEach(function(o) { - var sel = prefs.format === o.val ? ' selected' : ''; - fmtOptionsHtml += ''; - }); - + body.innerHTML = '
' + '
Appearance
' + @@ -175,24 +154,6 @@ function renderPanel(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); @@ -269,3 +230,66 @@ if (document.readyState === 'loading') { } else { initSettings(); } + +// Preferences page navigation +function initPreferences() { + var nav = document.getElementById('preferences-nav'); + if (!nav) return; + + var sections = document.querySelectorAll('.pref-section'); + var navItems = nav.querySelectorAll('.preferences-nav-item'); + + function showSection(id) { + sections.forEach(function(sec) { + sec.style.display = sec.id === 'section-' + id ? 'block' : 'none'; + }); + navItems.forEach(function(item) { + item.classList.toggle('active', item.getAttribute('data-section') === id); + }); + } + + navItems.forEach(function(item) { + item.addEventListener('click', function() { + showSection(item.getAttribute('data-section')); + }); + }); + + // Load saved preferences + var prefs = loadPrefs(); + + // Theme + var themeEl = document.getElementById('pref-theme'); + if (themeEl) { + themeEl.value = prefs.theme || 'system'; + themeEl.addEventListener('change', function() { + prefs.theme = themeEl.value; + savePrefs(prefs); + applyTheme(prefs.theme); + }); + } + + // Safe search + var ssEl = document.getElementById('pref-safesearch'); + if (ssEl) { + ssEl.value = prefs.safeSearch || 'moderate'; + ssEl.addEventListener('change', function() { + prefs.safeSearch = ssEl.value; + savePrefs(prefs); + }); + } + + // Format (if exists on page) + var fmtEl = document.getElementById('pref-format'); + if (fmtEl) { + fmtEl.value = prefs.format || 'html'; + fmtEl.addEventListener('change', function() { + prefs.format = fmtEl.value; + savePrefs(prefs); + }); + } + + // Show first section by default + showSection('search'); +} + +document.addEventListener('DOMContentLoaded', initPreferences); From e18a54a41a9baead10b949c961d45fe3baeaa20f Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 14:05:26 +0100 Subject: [PATCH 036/100] fix(frontend): add HTMX filter submission for sidebar radio buttons Wrap sidebar time/type filters in a form with HTMX attributes so filter changes trigger partial page updates instead of full reload. Co-Authored-By: Claude Opus 4.6 --- internal/views/templates/results.html | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/views/templates/results.html b/internal/views/templates/results.html index 59bc525..1e02fb9 100644 --- a/internal/views/templates/results.html +++ b/internal/views/templates/results.html @@ -20,7 +20,10 @@ {{end}} - + From 7969b724de014cf07a8fc7c4ba357bdb6d5ad0da Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 14:16:04 +0100 Subject: [PATCH 037/100] fix(engines): remove unsupported lookahead from Google regex Go's regexp package doesn't support Perl lookahead (?=...). Removing the unnecessary lookahead since each MjjYud div is self-contained. Co-Authored-By: Claude Opus 4.6 --- internal/engines/google.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/engines/google.go b/internal/engines/google.go index 8563829..77d8549 100644 --- a/internal/engines/google.go +++ b/internal/engines/google.go @@ -129,7 +129,7 @@ func detectGoogleSorry(resp *http.Response) bool { func parseGoogleResults(body, query string) []contracts.MainResult { var results []contracts.MainResult - mjjPattern := regexp.MustCompile(`]*class="[^"]*MjjYud[^"]*"[^>]*>(.*?)
\s*(?=]*class="[^"]*MjjYud|$)`) + mjjPattern := regexp.MustCompile(`]*class="[^"]*MjjYud[^"]*"[^>]*>(.*?)`) matches := mjjPattern.FindAllStringSubmatch(body, -1) for i, match := range matches { From 0b381c001ffa3bef6b19a65ab0dd7ce37ad45ceb Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:28:27 +0000 Subject: [PATCH 038/100] fix(flake): simplify preConfigure --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index e2521d7..ad62fd3 100644 --- a/flake.nix +++ b/flake.nix @@ -29,7 +29,7 @@ ldflags = [ "-s" "-w" ]; # Remove stale vendor directory before buildGoModule deletes it. - preConfigure = "find vendor -type f -exec chmod 666 {} \; 2>/dev/null || true; rm -rf vendor 2>/dev/null || find vendor -delete 2>/dev/null || true"; + preConfigure = "rm -rf vendor || true"; nativeCheckInputs = with pkgs; [ ]; From e2ff822847bec4a5be0717dc9e5f1ba6ec074ae9 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 14:05:28 +0000 Subject: [PATCH 039/100] fix(flake): set vendorHash to auto-compute The go.mod was updated with new replace directive for golang.org/x/net. Need to recompute vendorHash. Co-Authored-By: Claude Opus 4.6 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index ad62fd3..20ae248 100644 --- a/flake.nix +++ b/flake.nix @@ -21,7 +21,7 @@ version = "0.1.0"; src = ./.; - vendorHash = "sha256-PTD4eEEkLGBCZbot6W4U+sMOpIbH2tcFSztQel7hyXI="; + vendorHash = ""; # Run: nix build .#packages.x86_64-linux.default # It will fail with the correct hash. Replace vendorHash with it. From 994d27ff7f5c7b5c37964cf134f4b4ac005051aa Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 15:17:03 +0000 Subject: [PATCH 040/100] fix(flake): set correct vendorHash The correct vendorHash for current go.mod is: sha256-8wlKD+33s97oorCJTfHKAgE2Xp1HKXV+bSr6z29KrKM= Co-Authored-By: Claude Opus 4.6 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 20ae248..3552728 100644 --- a/flake.nix +++ b/flake.nix @@ -21,7 +21,7 @@ version = "0.1.0"; src = ./.; - vendorHash = ""; + vendorHash = "sha256-8wlKD+33s97oorCJTfHKAgE2Xp1HKXV+bSr6z29KrKM="; # Run: nix build .#packages.x86_64-linux.default # It will fail with the correct hash. Replace vendorHash with it. From 2d22a8cdbb636aaad40f1240b4a70877a649d61f Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 15:12:21 +0000 Subject: [PATCH 041/100] feat: add Brave web search scraper engine New brave.go: scrapes https://search.brave.com directly. Extracts title, URL, snippet, and favicon from Brave's HTML. No API key required. Rename existing BraveAPIEngine (was BraveEngine) to avoid collision with the new scraper. API engine stays as 'braveapi', scraper as 'brave'. --- internal/engines/brave.go | 172 +++++++++++++++++++++++++++++++++++ internal/engines/braveapi.go | 6 +- internal/engines/factory.go | 3 +- internal/engines/planner.go | 4 +- 4 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 internal/engines/brave.go diff --git a/internal/engines/brave.go b/internal/engines/brave.go new file mode 100644 index 0000000..cb9313d --- /dev/null +++ b/internal/engines/brave.go @@ -0,0 +1,172 @@ +package engines + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/metamorphosis-dev/kafka/internal/contracts" +) + +type BraveEngine struct { + client *http.Client +} + +func (e *BraveEngine) Name() string { return "brave" } + +func (e *BraveEngine) 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) * 20 + u := fmt.Sprintf( + "https://search.brave.com/search?q=%s&offset=%d&source=web", + url.QueryEscape(req.Query), + start, + ) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return contracts.SearchResponse{}, err + } + httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36") + httpReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + httpReq.Header.Set("Accept-Language", "en-US,en;q=0.9") + + 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("brave 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 := parseBraveResults(string(body)) + return contracts.SearchResponse{ + Query: req.Query, + NumberOfResults: len(results), + Results: results, + Answers: []map[string]any{}, + Corrections: []string{}, + Infoboxes: []map[string]any{}, + Suggestions: extractBraveSuggestions(string(body)), + UnresponsiveEngines: [][2]string{}, + }, nil +} + +func parseBraveResults(body string) []contracts.MainResult { + var results []contracts.MainResult + + // Brave wraps each result in divs with data-type="web" or data-type="news". + // Pattern:
... TITLE ...
SNIPPET
+ webPattern := regexp.MustCompile(`(?s)]+data-type="web"[^>]*>(.*?)
\s*]+data-type="(web|news)"`) + matches := webPattern.FindAllStringSubmatch(body, -1) + + seen := map[string]bool{} + + for _, match := range matches { + if len(match) < 2 { + continue + } + block := match[1] + + // Extract title and URL from the result-title link. + titlePattern := regexp.MustCompile(`]+class="result-title"[^>]+href="([^"]+)"[^>]*>([^<]+)`) + titleMatch := titlePattern.FindStringSubmatch(block) + if titleMatch == nil { + continue + } + href := titleMatch[1] + title := stripTags(titleMatch[2]) + + if href == "" || !strings.HasPrefix(href, "http") { + continue + } + if seen[href] { + continue + } + seen[href] = true + + // Extract snippet. + snippet := extractBraveSnippet(block) + + // Extract favicon URL. + favicon := extractBraveFavicon(block) + + urlPtr := href + results = append(results, contracts.MainResult{ + Title: title, + URL: &urlPtr, + Content: snippet, + Thumbnail: favicon, + Engine: "brave", + Score: 1.0, + Category: "general", + Engines: []string{"brave"}, + }) + } + + return results +} + +func extractBraveSnippet(block string) string { + // Try various snippet selectors Brave uses. + patterns := []string{ + `]+class="snippet"[^>]*>(.*?)`, + `]+class="[^"]*description[^"]*"[^>]*>(.*?)

`, + `]+class="[^"]*snippet[^"]*"[^>]*>(.*?)`, + } + + for _, pat := range patterns { + re := regexp.MustCompile(`(?s)` + pat) + m := re.FindStringSubmatch(block) + if len(m) >= 2 { + text := stripTags(m[1]) + if text != "" { + return strings.TrimSpace(text) + } + } + } + return "" +} + +func extractBraveFavicon(block string) string { + imgPattern := regexp.MustCompile(`]+class="[^"]*favicon[^"]*"[^>]+src="([^"]+)"`) + m := imgPattern.FindStringSubmatch(block) + if len(m) >= 2 { + return m[1] + } + return "" +} + +func extractBraveSuggestions(body string) []string { + var suggestions []string + // Brave suggestions appear in a dropdown or related searches section. + suggestPattern := regexp.MustCompile(`(?s)]+class="[^"]*suggestion[^"]*"[^>]*>.*?]*>([^<]+)`) + matches := suggestPattern.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 +} diff --git a/internal/engines/braveapi.go b/internal/engines/braveapi.go index 8977cb2..1ae6220 100644 --- a/internal/engines/braveapi.go +++ b/internal/engines/braveapi.go @@ -33,16 +33,16 @@ import ( // BraveEngine implements the Brave Web Search API. // Required: BRAVE_API_KEY env var or config. // Optional: BRAVE_ACCESS_TOKEN to gate requests. -type BraveEngine struct { +type BraveAPIEngine struct { client *http.Client apiKey string accessGateToken string resultsPerPage int } -func (e *BraveEngine) Name() string { return "braveapi" } +func (e *BraveAPIEngine) Name() string { return "braveapi" } -func (e *BraveEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) { +func (e *BraveAPIEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) { if e == nil || e.client == nil { return contracts.SearchResponse{}, errors.New("brave engine not initialized") } diff --git a/internal/engines/factory.go b/internal/engines/factory.go index 528dcb7..68f66eb 100644 --- a/internal/engines/factory.go +++ b/internal/engines/factory.go @@ -51,12 +51,13 @@ func NewDefaultPortedEngines(client *http.Client, cfg *config.Config) map[string "wikipedia": &WikipediaEngine{client: client}, "arxiv": &ArxivEngine{client: client}, "crossref": &CrossrefEngine{client: client}, - "braveapi": &BraveEngine{ + "braveapi": &BraveAPIEngine{ client: client, apiKey: braveAPIKey, accessGateToken: braveAccessToken, resultsPerPage: 20, }, + "brave": &BraveEngine{client: client}, "qwant": &QwantEngine{ client: client, category: "web-lite", diff --git a/internal/engines/planner.go b/internal/engines/planner.go index 9616a4b..270885b 100644 --- a/internal/engines/planner.go +++ b/internal/engines/planner.go @@ -23,7 +23,7 @@ import ( "github.com/metamorphosis-dev/kafka/internal/contracts" ) -var defaultPortedEngines = []string{"wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing", "google", "youtube"} +var defaultPortedEngines = []string{"wikipedia", "arxiv", "crossref", "braveapi", "brave", "qwant", "duckduckgo", "github", "reddit", "bing", "google", "youtube"} type Planner struct { PortedSet map[string]bool @@ -122,7 +122,7 @@ func inferFromCategories(categories []string) []string { out = append(out, e) } // stable order - 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} + order := map[string]int{"wikipedia": 0, "braveapi": 1, "brave": 2, "qwant": 3, "duckduckgo": 4, "bing": 5, "google": 6, "arxiv": 7, "crossref": 8, "github": 9, "reddit": 10, "youtube": 11} sortByOrder(out, order) return out } From 4b0cde91edbda48aecc1883da045ee0964f634d1 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 15:24:40 +0000 Subject: [PATCH 042/100] feat: 3-column layout with centered results and right column - results-layout: 3-column grid (1fr | min(768px,100%) | 300px) max-width 1400px, centered - Widen center results column to 768px max - Right column (formerly sidebar): sticky, contains knowledge panel + related searches - Knowledge panel: Wikipedia/infobox summary with optional thumbnail - Related searches: clickable links to refine the query - Empty left buffer creates balanced whitespace on large screens - Responsive: 2-col at 1000px, 1-col at 700px --- internal/views/static/css/kafka.css | 77 ++++++++++++-- internal/views/templates/results.html | 111 ++++++-------------- internal/views/templates/results_inner.html | 7 -- internal/views/views.go | 2 +- 4 files changed, 105 insertions(+), 92 deletions(-) diff --git a/internal/views/static/css/kafka.css b/internal/views/static/css/kafka.css index ad094e9..40f0f25 100644 --- a/internal/views/static/css/kafka.css +++ b/internal/views/static/css/kafka.css @@ -235,11 +235,22 @@ main { padding-top: 1.5rem; } +/* 3-column layout: empty left buffer | center results | right column + max-width 1400px, centered on page */ .results-layout { display: grid; - grid-template-columns: 1fr 220px; + grid-template-columns: 1fr min(768px, 100%) 300px; gap: 2rem; align-items: start; + max-width: 1400px; + margin: 0 auto; +} + +@media (max-width: 1000px) { + .results-layout { + grid-template-columns: 1fr 260px; + gap: 1.5rem; + } } @media (max-width: 700px) { @@ -249,7 +260,7 @@ main { } } -/* Compact search bar on results page */ +/* Compact search bar spans all columns */ .search-compact { grid-column: 1 / -1; } @@ -380,12 +391,15 @@ main { } /* ============================================================ - Sidebar + Right Column (formerly sidebar) ============================================================ */ -.sidebar { +.right-column { position: sticky; top: calc(var(--header-height) + 1.5rem); + display: flex; + flex-direction: column; + gap: 1rem; } .sidebar-card { @@ -393,18 +407,65 @@ main { border: 1px solid var(--border); border-radius: var(--radius-md); padding: 1rem; - margin-bottom: 1rem; } -.sidebar-title { - font-size: 0.75rem; +.sidebar-card-title { + font-size: 0.7rem; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 0.75rem; } +/* Knowledge Panel card */ +.knowledge-panel { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 1rem; +} + +.knowledge-panel-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.5rem; +} + +.knowledge-panel-content { + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.55; +} + +.knowledge-panel-thumb { + width: 100%; + border-radius: var(--radius-sm); + margin-bottom: 0.75rem; +} + +/* Related searches */ +.related-searches { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.related-search-link { + font-size: 0.85rem; + color: var(--accent); + text-decoration: none; + padding: 0.25rem 0; + border-radius: var(--radius-sm); + transition: color 0.15s; +} + +.related-search-link:hover { + color: var(--accent-hover); + text-decoration: underline; +} + /* Suggestions in sidebar */ .suggestion-list { display: flex; diff --git a/internal/views/templates/results.html b/internal/views/templates/results.html index 1e02fb9..74d5f93 100644 --- a/internal/views/templates/results.html +++ b/internal/views/templates/results.html @@ -1,94 +1,53 @@ {{define "title"}}{{if .Query}}{{.Query}} — {{end}}{{end}} {{define "content"}}
- - - - -
- -
- + +
+ +
- -
- All - {{range .Categories}} - {{.}} - {{end}} -
- - + +
{{template "results_inner" .}}
- -
-{{end}} \ No newline at end of file +{{end}} diff --git a/internal/views/templates/results_inner.html b/internal/views/templates/results_inner.html index 0f9450a..cca51d1 100644 --- a/internal/views/templates/results_inner.html +++ b/internal/views/templates/results_inner.html @@ -8,13 +8,6 @@ {{range .Answers}}
{{.}}
{{end}} - {{range .Infoboxes}} -
- {{if .title}}
{{.title}}
{{end}} - {{if .content}}
{{.content}}
{{end}} - {{if .img_src}}{{.title}}{{end}} -
- {{end}}
{{end}} diff --git a/internal/views/views.go b/internal/views/views.go index 2dec5a7..6f23937 100644 --- a/internal/views/views.go +++ b/internal/views/views.go @@ -110,7 +110,7 @@ func init() { "base.html", "index.html", )) tmplFragment = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, - "results_inner.html", "result_item.html", "video_item.html", + "results.html", "results_inner.html", "result_item.html", "video_item.html", )) tmplPreferences = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, "base.html", "preferences.html", From da367a1bfd5c0c8f86853ba9f664caa7389d5087 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 16:22:27 +0000 Subject: [PATCH 043/100] security: harden against SAST findings (criticals through mediums) Critical: - Validate baseURL/sourceURL/upstreamURL at config load time (prevents XML injection, XSS, SSRF via config/env manipulation) - Use xml.Escape for OpenSearch XML template interpolation High: - Add security headers middleware (CSP, X-Frame-Options, HSTS, etc.) - Sanitize result URLs to reject javascript:/data: schemes - Sanitize infobox img_src against dangerous URL schemes - Default CORS to deny-all (was wildcard *) Medium: - Rate limiter: X-Forwarded-For only trusted from configured proxies - Validate engine names against known registry allowlist - Add 1024-char max query length - Sanitize upstream error messages (strip raw response bodies) - Upstream client validates URL scheme (http/https only) Test updates: - Update extractIP tests for new trusted proxy behavior --- cmd/kafka/main.go | 4 +- internal/config/config.go | 29 ++++++ internal/engines/arxiv.go | 2 +- internal/engines/bing.go | 2 +- internal/engines/brave.go | 2 +- internal/engines/braveapi.go | 2 +- internal/engines/crossref.go | 2 +- internal/engines/duckduckgo.go | 2 +- internal/engines/github.go | 2 +- internal/engines/google.go | 2 +- internal/engines/qwant.go | 4 +- internal/engines/reddit.go | 2 +- internal/engines/wikipedia.go | 2 +- internal/engines/youtube.go | 4 +- internal/middleware/cors.go | 4 +- internal/middleware/ratelimit.go | 71 +++++++++++--- internal/middleware/ratelimit_global.go | 2 +- internal/middleware/ratelimit_test.go | 34 +++++-- internal/middleware/security.go | 92 ++++++++++++++++++ internal/search/request_params.go | 27 ++++++ internal/upstream/client.go | 5 +- internal/util/validate.go | 123 ++++++++++++++++++++++++ internal/views/views.go | 21 +++- 23 files changed, 399 insertions(+), 41 deletions(-) create mode 100644 internal/middleware/security.go create mode 100644 internal/util/validate.go diff --git a/cmd/kafka/main.go b/cmd/kafka/main.go index 3a0a80e..f691665 100644 --- a/cmd/kafka/main.go +++ b/cmd/kafka/main.go @@ -95,8 +95,9 @@ func main() { var subFS fs.FS = staticFS mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(subFS)))) - // Apply middleware: global rate limit → burst rate limit → per-IP rate limit → CORS → handler. + // Apply middleware: global rate limit → burst rate limit → per-IP rate limit → CORS → security headers → handler. var handler http.Handler = mux + handler = middleware.SecurityHeaders(middleware.SecurityHeadersConfig{})(handler) handler = middleware.CORS(middleware.CORSConfig{ AllowedOrigins: cfg.CORS.AllowedOrigins, AllowedMethods: cfg.CORS.AllowedMethods, @@ -108,6 +109,7 @@ func main() { Requests: cfg.RateLimit.Requests, Window: cfg.RateLimitWindow(), CleanupInterval: cfg.RateLimitCleanupInterval(), + TrustedProxies: cfg.RateLimit.TrustedProxies, }, logger)(handler) handler = middleware.GlobalRateLimit(middleware.GlobalRateLimitConfig{ Requests: cfg.GlobalRateLimit.Requests, diff --git a/internal/config/config.go b/internal/config/config.go index e5d1fbb..42aac23 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,11 +18,13 @@ package config import ( "fmt" + "log" "os" "strings" "time" "github.com/BurntSushi/toml" + "github.com/metamorphosis-dev/kafka/internal/util" ) // Config is the top-level configuration for the kafka service. @@ -77,6 +79,7 @@ type RateLimitConfig struct { Requests int `toml:"requests"` // Max requests per window (default: 30) Window string `toml:"window"` // Time window (e.g. "1m", default: "1m") CleanupInterval string `toml:"cleanup_interval"` // Stale entry cleanup interval (default: "5m") + TrustedProxies []string `toml:"trusted_proxies"` // CIDRs allowed to set X-Forwarded-For } // GlobalRateLimitConfig holds server-wide rate limiting settings. @@ -120,9 +123,35 @@ func Load(path string) (*Config, error) { } applyEnvOverrides(cfg) + + if err := validateConfig(cfg); err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) + } + return cfg, nil } +// validateConfig checks security-critical config values at startup. +func validateConfig(cfg *Config) error { + if cfg.Server.BaseURL != "" { + if err := util.ValidatePublicURL(cfg.Server.BaseURL); err != nil { + return fmt.Errorf("server.base_url: %w", err) + } + } + if cfg.Server.SourceURL != "" { + if err := util.ValidatePublicURL(cfg.Server.SourceURL); err != nil { + return fmt.Errorf("server.source_url: %w", err) + } + } + if cfg.Upstream.URL != "" { + if err := util.ValidatePublicURL(cfg.Upstream.URL); err != nil { + return fmt.Errorf("upstream.url: %w", err) + } + log.Printf("WARNING: upstream.url SSRF protection is enabled; ensure the upstream host is not on a private network") + } + return nil +} + func defaultConfig() *Config { return &Config{ Server: ServerConfig{ diff --git a/internal/engines/arxiv.go b/internal/engines/arxiv.go index 1347562..111e3b7 100644 --- a/internal/engines/arxiv.go +++ b/internal/engines/arxiv.go @@ -76,7 +76,7 @@ func (e *ArxivEngine) Search(ctx context.Context, req contracts.SearchRequest) ( if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) - return contracts.SearchResponse{}, fmt.Errorf("arxiv upstream error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("arxiv upstream error: status %d", resp.StatusCode) } raw, err := io.ReadAll(resp.Body) diff --git a/internal/engines/bing.go b/internal/engines/bing.go index 85c3f65..b1abbab 100644 --- a/internal/engines/bing.go +++ b/internal/engines/bing.go @@ -69,7 +69,7 @@ func (e *BingEngine) Search(ctx context.Context, req contracts.SearchRequest) (c if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return contracts.SearchResponse{}, fmt.Errorf("bing upstream error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("bing upstream error: status %d", resp.StatusCode) } contentType := resp.Header.Get("Content-Type") diff --git a/internal/engines/brave.go b/internal/engines/brave.go index cb9313d..373bc47 100644 --- a/internal/engines/brave.go +++ b/internal/engines/brave.go @@ -46,7 +46,7 @@ func (e *BraveEngine) Search(ctx context.Context, req contracts.SearchRequest) ( if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return contracts.SearchResponse{}, fmt.Errorf("brave error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("brave error: status %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 128*1024)) diff --git a/internal/engines/braveapi.go b/internal/engines/braveapi.go index 1ae6220..6b89347 100644 --- a/internal/engines/braveapi.go +++ b/internal/engines/braveapi.go @@ -128,7 +128,7 @@ func (e *BraveAPIEngine) Search(ctx context.Context, req contracts.SearchRequest if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) - return contracts.SearchResponse{}, fmt.Errorf("brave upstream error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("brave upstream error: status %d", resp.StatusCode) } var api struct { diff --git a/internal/engines/crossref.go b/internal/engines/crossref.go index cc33759..d911034 100644 --- a/internal/engines/crossref.go +++ b/internal/engines/crossref.go @@ -64,7 +64,7 @@ func (e *CrossrefEngine) Search(ctx context.Context, req contracts.SearchRequest if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) - return contracts.SearchResponse{}, fmt.Errorf("crossref upstream error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("crossref upstream error: status %d", resp.StatusCode) } var api struct { diff --git a/internal/engines/duckduckgo.go b/internal/engines/duckduckgo.go index 158d483..9aa275e 100644 --- a/internal/engines/duckduckgo.go +++ b/internal/engines/duckduckgo.go @@ -64,7 +64,7 @@ func (e *DuckDuckGoEngine) Search(ctx context.Context, req contracts.SearchReque if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return contracts.SearchResponse{}, fmt.Errorf("duckduckgo upstream error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("duckduckgo upstream error: status %d", resp.StatusCode) } results, err := parseDuckDuckGoHTML(resp.Body) diff --git a/internal/engines/github.go b/internal/engines/github.go index f37cddc..13d85b8 100644 --- a/internal/engines/github.go +++ b/internal/engines/github.go @@ -67,7 +67,7 @@ func (e *GitHubEngine) Search(ctx context.Context, req contracts.SearchRequest) if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return contracts.SearchResponse{}, fmt.Errorf("github api error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("github api error: status %d", resp.StatusCode) } var data struct { diff --git a/internal/engines/google.go b/internal/engines/google.go index 77d8549..49e2dbe 100644 --- a/internal/engines/google.go +++ b/internal/engines/google.go @@ -96,7 +96,7 @@ func (e *GoogleEngine) Search(ctx context.Context, req contracts.SearchRequest) 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)) + return contracts.SearchResponse{}, fmt.Errorf("google error: status %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 128*1024)) diff --git a/internal/engines/qwant.go b/internal/engines/qwant.go index e15d4f2..1c4876b 100644 --- a/internal/engines/qwant.go +++ b/internal/engines/qwant.go @@ -125,7 +125,7 @@ func (e *QwantEngine) searchWebAPI(ctx context.Context, req contracts.SearchRequ if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) - return contracts.SearchResponse{}, fmt.Errorf("qwant upstream error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("qwant upstream error: status %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) @@ -254,7 +254,7 @@ func (e *QwantEngine) searchWebLite(ctx context.Context, req contracts.SearchReq if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) - return contracts.SearchResponse{}, fmt.Errorf("qwant lite upstream error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("qwant lite upstream error: status %d", resp.StatusCode) } doc, err := goquery.NewDocumentFromReader(resp.Body) diff --git a/internal/engines/reddit.go b/internal/engines/reddit.go index 788f52a..cb75cf9 100644 --- a/internal/engines/reddit.go +++ b/internal/engines/reddit.go @@ -63,7 +63,7 @@ func (e *RedditEngine) Search(ctx context.Context, req contracts.SearchRequest) if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return contracts.SearchResponse{}, fmt.Errorf("reddit api error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("reddit api error: status %d", resp.StatusCode) } var data struct { diff --git a/internal/engines/wikipedia.go b/internal/engines/wikipedia.go index f29ff74..3a65749 100644 --- a/internal/engines/wikipedia.go +++ b/internal/engines/wikipedia.go @@ -135,7 +135,7 @@ func (e *WikipediaEngine) Search(ctx context.Context, req contracts.SearchReques } if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) - return contracts.SearchResponse{}, fmt.Errorf("wikipedia upstream error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("wikipedia upstream error: status %d", resp.StatusCode) } var api struct { diff --git a/internal/engines/youtube.go b/internal/engines/youtube.go index 5946aa4..ec0add9 100644 --- a/internal/engines/youtube.go +++ b/internal/engines/youtube.go @@ -78,7 +78,7 @@ func (e *YouTubeEngine) Search(ctx context.Context, req contracts.SearchRequest) 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)) + return contracts.SearchResponse{}, fmt.Errorf("youtube api error: status %d", resp.StatusCode) } var apiResp youtubeSearchResponse @@ -87,7 +87,7 @@ func (e *YouTubeEngine) Search(ctx context.Context, req contracts.SearchRequest) } if apiResp.Error != nil { - return contracts.SearchResponse{}, fmt.Errorf("youtube api error: %s", apiResp.Error.Message) + return contracts.SearchResponse{}, fmt.Errorf("youtube api error: code %d", apiResp.Error.Code) } results := make([]contracts.MainResult, 0, len(apiResp.Items)) diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go index ee90ab0..d4ecf2a 100644 --- a/internal/middleware/cors.go +++ b/internal/middleware/cors.go @@ -42,7 +42,8 @@ type CORSConfig struct { func CORS(cfg CORSConfig) func(http.Handler) http.Handler { origins := cfg.AllowedOrigins if len(origins) == 0 { - origins = []string{"*"} + // Default: no CORS headers. Explicitly configure origins to enable. + origins = nil } methods := cfg.AllowedMethods @@ -70,6 +71,7 @@ func CORS(cfg CORSConfig) func(http.Handler) http.Handler { origin := r.Header.Get("Origin") // Determine the allowed origin for this request. + // If no origins are configured, CORS is disabled entirely — no headers are set. allowedOrigin := "" for _, o := range origins { if o == "*" { diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go index 78774f2..8bd1123 100644 --- a/internal/middleware/ratelimit.go +++ b/internal/middleware/ratelimit.go @@ -27,10 +27,14 @@ import ( "log/slog" ) +// RateLimitConfig controls per-IP rate limiting. type RateLimitConfig struct { Requests int Window time.Duration CleanupInterval time.Duration + // TrustedProxies is a list of CIDR ranges that are allowed to set + // X-Forwarded-For / X-Real-IP. If empty, only r.RemoteAddr is used. + TrustedProxies []string } func RateLimit(cfg RateLimitConfig, logger *slog.Logger) func(http.Handler) http.Handler { @@ -53,18 +57,30 @@ func RateLimit(cfg RateLimitConfig, logger *slog.Logger) func(http.Handler) http logger = slog.Default() } + // Parse trusted proxy CIDRs. + var trustedNets []*net.IPNet + for _, cidr := range cfg.TrustedProxies { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + logger.Warn("invalid trusted proxy CIDR, skipping", "cidr", cidr, "error", err) + continue + } + trustedNets = append(trustedNets, network) + } + limiter := &ipLimiter{ requests: requests, window: window, clients: make(map[string]*bucket), logger: logger, + trusted: trustedNets, } go limiter.cleanup(cleanup) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ip := extractIP(r) + ip := l.extractIP(r) if !limiter.allow(ip) { retryAfter := int(limiter.window.Seconds()) @@ -92,6 +108,7 @@ type ipLimiter struct { clients map[string]*bucket mu sync.Mutex logger *slog.Logger + trusted []*net.IPNet } func (l *ipLimiter) allow(ip string) bool { @@ -129,18 +146,48 @@ func (l *ipLimiter) cleanup(interval time.Duration) { } } -func extractIP(r *http.Request) string { - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - parts := strings.SplitN(xff, ",", 2) - return strings.TrimSpace(parts[0]) - } - if rip := r.Header.Get("X-Real-IP"); rip != "" { - return strings.TrimSpace(rip) +// extractIP extracts the client IP from the request. +// If trusted proxy CIDRs are configured, X-Forwarded-For is only used when +// the direct connection comes from a trusted proxy. Otherwise, only RemoteAddr is used. +func (l *ipLimiter) extractIP(r *http.Request) string { + return extractIP(r, l.trusted...) +} + +func extractIP(r *http.Request, trusted ...*net.IPNet) string { + remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + remoteIP = r.RemoteAddr } - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - return r.RemoteAddr + // Check if the direct connection is from a trusted proxy. + isTrusted := false + if len(trusted) > 0 { + ip := net.ParseIP(remoteIP) + if ip != nil { + for _, network := range trusted { + if network.Contains(ip) { + isTrusted = true + break + } + } + } } - return host + + if isTrusted { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.SplitN(xff, ",", 2) + candidate := strings.TrimSpace(parts[0]) + if net.ParseIP(candidate) != nil { + return candidate + } + } + if rip := r.Header.Get("X-Real-IP"); rip != "" { + candidate := strings.TrimSpace(rip) + if net.ParseIP(candidate) != nil { + return candidate + } + } + } + + return remoteIP } diff --git a/internal/middleware/ratelimit_global.go b/internal/middleware/ratelimit_global.go index 538c435..0bd34c5 100644 --- a/internal/middleware/ratelimit_global.go +++ b/internal/middleware/ratelimit_global.go @@ -71,7 +71,7 @@ func GlobalRateLimit(cfg GlobalRateLimitConfig, logger *slog.Logger) func(http.H w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusServiceUnavailable) _, _ = w.Write([]byte("503 Service Unavailable — global rate limit exceeded\n")) - logger.Warn("global rate limit exceeded", "ip", extractIP(r)) + logger.Warn("global rate limit exceeded", "remote", r.RemoteAddr) return } diff --git a/internal/middleware/ratelimit_test.go b/internal/middleware/ratelimit_test.go index 987d014..514d985 100644 --- a/internal/middleware/ratelimit_test.go +++ b/internal/middleware/ratelimit_test.go @@ -92,9 +92,11 @@ func TestRateLimit_DifferentIPs(t *testing.T) { } func TestRateLimit_XForwardedFor(t *testing.T) { + privateNet := mustParseCIDR("10.0.0.0/8") h := RateLimit(RateLimitConfig{ - Requests: 1, - Window: 10 * time.Second, + Requests: 1, + Window: 10 * time.Second, + TrustedProxies: []string{"10.0.0.0/8"}, }, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) @@ -143,17 +145,27 @@ func TestRateLimit_WindowExpires(t *testing.T) { } func TestExtractIP(t *testing.T) { + // Trusted proxy: loopback + loopback := mustParseCIDR("127.0.0.0/8") + privateNet := mustParseCIDR("10.0.0.0/8") + tests := []struct { name string xff string realIP string remote string + trusted []*net.IPNet expected string }{ - {"xff", "203.0.113.50, 10.0.0.1", "", "10.0.0.1:1234", "203.0.113.50"}, - {"real_ip", "", "203.0.113.50", "10.0.0.1:1234", "203.0.113.50"}, - {"remote", "", "", "1.2.3.4:5678", "1.2.3.4"}, - {"xff_over_real", "203.0.113.50", "10.0.0.1", "10.0.0.1:1234", "203.0.113.50"}, + // No trusted proxies → always use RemoteAddr. + {"no_trusted_xff", "203.0.113.50, 10.0.0.1", "", "10.0.0.1:1234", nil, "10.0.0.1"}, + {"no_trusted_real", "", "203.0.113.50", "10.0.0.1:1234", nil, "10.0.0.1"}, + {"no_trusted_remote", "", "", "1.2.3.4:5678", nil, "1.2.3.4"}, + // Trusted proxy → XFF is respected. + {"trusted_xff", "203.0.113.50, 10.0.0.1", "", "10.0.0.1:1234", []*net.IPNet{privateNet}, "203.0.113.50"}, + {"trusted_real_ip", "", "203.0.113.50", "10.0.0.1:1234", []*net.IPNet{privateNet}, "203.0.113.50"}, + // Untrusted remote → XFF ignored even if present. + {"untrusted_xff", "203.0.113.50, 10.0.0.1", "", "1.2.3.4:5678", []*net.IPNet{loopback}, "1.2.3.4"}, } for _, tt := range tests { @@ -167,9 +179,17 @@ func TestExtractIP(t *testing.T) { } req.RemoteAddr = tt.remote - if got := extractIP(req); got != tt.expected { + if got := extractIP(req, tt.trusted...); got != tt.expected { t.Errorf("extractIP() = %q, want %q", got, tt.expected) } }) } } + +func mustParseCIDR(s string) *net.IPNet { + _, network, err := net.ParseCIDR(s) + if err != nil { + panic(err) + } + return network +} diff --git a/internal/middleware/security.go b/internal/middleware/security.go new file mode 100644 index 0000000..09f3878 --- /dev/null +++ b/internal/middleware/security.go @@ -0,0 +1,92 @@ +// kafka — a privacy-respecting metasearch engine +// Copyright (C) 2026-present metamorphosis-dev +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package middleware + +import ( + "net/http" + "strconv" + "strings" +) + +// SecurityHeadersConfig controls which security headers are set. +type SecurityHeadersConfig struct { + // FrameOptions controls X-Frame-Options. Default: "DENY". + FrameOptions string + // HSTSMaxAge controls the max-age for Strict-Transport-Security. + // Set to 0 to disable HSTS (useful for local dev). Default: 31536000 (1 year). + HSTSMaxAge int + // HSTSPreloadDomains adds "includeSubDomains; preload" to HSTS. + HSTSPreloadDomains bool + // ReferrerPolicy controls the Referrer-Policy header. Default: "no-referrer". + ReferrerPolicy string + // CSP controls Content-Security-Policy. Default: a restrictive policy. + // Set to "" to disable CSP entirely. + CSP string +} + +// SecurityHeaders returns middleware that sets standard HTTP security headers +// on every response. +func SecurityHeaders(cfg SecurityHeadersConfig) func(http.Handler) http.Handler { + frameOpts := cfg.FrameOptions + if frameOpts == "" { + frameOpts = "DENY" + } + + hstsAge := cfg.HSTSMaxAge + if hstsAge == 0 { + hstsAge = 31536000 // 1 year + } + + refPol := cfg.ReferrerPolicy + if refPol == "" { + refPol = "no-referrer" + } + + csp := cfg.CSP + if csp == "" { + csp = defaultCSP() + } + + hstsValue := "max-age=" + strconv.Itoa(hstsAge) + if cfg.HSTSPreloadDomains { + hstsValue += "; includeSubDomains; preload" + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", frameOpts) + w.Header().Set("Referrer-Policy", refPol) + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + w.Header().Set("Content-Security-Policy", csp) + + if hstsAge > 0 { + w.Header().Set("Strict-Transport-Security", hstsValue) + } + + next.ServeHTTP(w, r) + }) + } +} + +// defaultCSP returns a restrictive Content-Security-Policy for the +// metasearch engine. +func defaultCSP() string { + return strings.Join([]string{ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' https: data:", + "connect-src 'self'", + "font-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + }, "; ") +} diff --git a/internal/search/request_params.go b/internal/search/request_params.go index baad193..2e477fb 100644 --- a/internal/search/request_params.go +++ b/internal/search/request_params.go @@ -26,6 +26,28 @@ import ( var languageCodeRe = regexp.MustCompile(`^[a-z]{2,3}(-[a-zA-Z]{2})?$`) +// maxQueryLength is the maximum allowed length for the search query. +const maxQueryLength = 1024 + +// knownEngineNames is the allowlist of valid engine identifiers. +var knownEngineNames = map[string]bool{ + "wikipedia": true, "arxiv": true, "crossref": true, + "braveapi": true, "brave": true, "qwant": true, + "duckduckgo": true, "github": true, "reddit": true, + "bing": true, "google": true, "youtube": true, +} + +// validateEngines filters engine names against the known registry. +func validateEngines(engines []string) []string { + out := make([]string, 0, len(engines)) + for _, e := range engines { + if knownEngineNames[strings.ToLower(e)] { + out = append(out, strings.ToLower(e)) + } + } + return out +} + func ParseSearchRequest(r *http.Request) (SearchRequest, error) { // Supports both GET and POST and relies on form values for routing. if err := r.ParseForm(); err != nil { @@ -50,6 +72,9 @@ func ParseSearchRequest(r *http.Request) (SearchRequest, error) { if strings.TrimSpace(q) == "" { return SearchRequest{}, errors.New("missing required parameter: q") } + if len(q) > maxQueryLength { + return SearchRequest{}, errors.New("query exceeds maximum length") + } pageno := 1 if s := strings.TrimSpace(r.FormValue("pageno")); s != "" { @@ -105,6 +130,8 @@ func ParseSearchRequest(r *http.Request) (SearchRequest, error) { // engines is an explicit list of engine names. engines := splitCSV(strings.TrimSpace(r.FormValue("engines"))) + // Validate engine names against known registry to prevent injection. + engines = validateEngines(engines) // categories and category_ params mirror the webadapter parsing. // We don't validate against a registry here; we just preserve the requested values. diff --git a/internal/upstream/client.go b/internal/upstream/client.go index 2bff509..27d74b6 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -44,6 +44,9 @@ func NewClient(baseURL string, timeout time.Duration) (*Client, error) { if err != nil { return nil, fmt.Errorf("invalid upstream base URL: %w", err) } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, fmt.Errorf("upstream URL must use http or https, got %q", u.Scheme) + } // Normalize: trim trailing slash to make URL concatenation predictable. base := strings.TrimRight(u.String(), "/") @@ -108,7 +111,7 @@ func (c *Client) SearchJSON(ctx context.Context, req contracts.SearchRequest, en } if resp.StatusCode != http.StatusOK { - return contracts.SearchResponse{}, fmt.Errorf("upstream search failed: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("upstream search failed with status %d", resp.StatusCode) } // Decode upstream JSON into our contract types. diff --git a/internal/util/validate.go b/internal/util/validate.go new file mode 100644 index 0000000..2ea31cc --- /dev/null +++ b/internal/util/validate.go @@ -0,0 +1,123 @@ +// kafka — a privacy-respecting metasearch engine +// Copyright (C) 2026-present metamorphosis-dev +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License,// or (at your option) any later version. + +package util + +import ( + "fmt" + "net" + "net/url" + "strings" +) + +// SafeURLScheme returns true if the URL uses an acceptable scheme (http or https). +func SafeURLScheme(raw string) bool { + u, err := url.Parse(raw) + if err != nil { + return false + } + return u.Scheme == "http" || u.Scheme == "https" +} + +// IsPrivateIP returns true if the IP address is in a private, loopback, +// link-local, or otherwise non-routable range. +func IsPrivateIP(host string) bool { + // Strip port if present. + h, _, err := net.SplitHostPort(host) + if err != nil { + h = host + } + + // Resolve hostname to IPs. + ips, err := net.LookupIP(h) + if err != nil || len(ips) == 0 { + // If we can't resolve, reject to be safe. + return true + } + + for _, ip := range ips { + if isPrivateIPAddr(ip) { + return true + } + } + return false +} + +func isPrivateIPAddr(ip net.IP) bool { + privateRanges := []struct { + network *net.IPNet + }{ + // Loopback + {mustParseCIDR("127.0.0.0/8")}, + {mustParseCIDR("::1/128")}, + // RFC 1918 + {mustParseCIDR("10.0.0.0/8")}, + {mustParseCIDR("172.16.0.0/12")}, + {mustParseCIDR("192.168.0.0/16")}, + // RFC 6598 (Carrier-grade NAT) + {mustParseCIDR("100.64.0.0/10")}, + // Link-local + {mustParseCIDR("169.254.0.0/16")}, + {mustParseCIDR("fe80::/10")}, + // IPv6 unique local + {mustParseCIDR("fc00::/7")}, + // IPv4-mapped IPv6 loopback + {mustParseCIDR("::ffff:127.0.0.0/104")}, + } + + for _, r := range privateRanges { + if r.network.Contains(ip) { + return true + } + } + return false +} + +func mustParseCIDR(s string) *net.IPNet { + _, network, err := net.ParseCIDR(s) + if err != nil { + panic(fmt.Sprintf("validate: invalid CIDR %q: %v", s, err)) + } + return network +} + +// ValidatePublicURL checks that a URL is well-formed, uses http or https, +// and does not point to a private/reserved IP range. +func ValidatePublicURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("URL must use http or https, got %q", u.Scheme) + } + if u.Host == "" { + return fmt.Errorf("URL must have a host") + } + if IsPrivateIP(u.Host) { + return fmt.Errorf("URL points to a private or reserved address: %s", u.Host) + } + return nil +} + +// SanitizeResultURL ensures a URL is safe for rendering in an href attribute. +// It rejects javascript:, data:, vbscript: and other dangerous schemes. +func SanitizeResultURL(raw string) string { + if raw == "" { + return "" + } + u, err := url.Parse(raw) + if err != nil { + return "" + } + switch strings.ToLower(u.Scheme) { + case "http", "https", "": + return raw + default: + return "" + } +} diff --git a/internal/views/views.go b/internal/views/views.go index 6f23937..c176f81 100644 --- a/internal/views/views.go +++ b/internal/views/views.go @@ -18,6 +18,7 @@ package views import ( "embed" + "encoding/xml" "html/template" "io/fs" "net/http" @@ -25,6 +26,7 @@ import ( "strings" "github.com/metamorphosis-dev/kafka/internal/contracts" + "github.com/metamorphosis-dev/kafka/internal/util" ) //go:embed all:templates @@ -122,15 +124,20 @@ func StaticFS() (fs.FS, error) { return fs.Sub(staticFS, "static") } -// OpenSearchXML returns the OpenSearch description XML with {baseUrl} -// replaced by the provided base URL. +// OpenSearchXML returns the OpenSearch description XML with the base URL +// safely embedded via xml.EscapeText (no raw string interpolation). func OpenSearchXML(baseURL string) ([]byte, error) { tmplFS, _ := fs.Sub(templatesFS, "templates") data, err := fs.ReadFile(tmplFS, "opensearch.xml") if err != nil { return nil, err } - result := strings.ReplaceAll(string(data), "{baseUrl}", baseURL) + + var buf strings.Builder + xml.Escape(&buf, []byte(baseURL)) + escapedBaseURL := buf.String() + + result := strings.ReplaceAll(string(data), "{baseUrl}", escapedBaseURL) return []byte(result), nil } @@ -190,6 +197,12 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int, activ if r.Template == "videos" { tmplName = "video_item" } + // Sanitize URLs to prevent javascript:/data: scheme injection. + if r.URL != nil { + safe := util.SanitizeResultURL(*r.URL) + r.URL = &safe + } + r.Thumbnail = util.SanitizeResultURL(r.Thumbnail) pd.Results[i] = ResultView{MainResult: r, TemplateName: tmplName} } @@ -213,7 +226,7 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int, activ iv.Title = v } if v, ok := ib["img_src"].(string); ok { - iv.ImgSrc = v + iv.ImgSrc = util.SanitizeResultURL(v) } if iv.Title != "" || iv.Content != "" { pd.Infoboxes = append(pd.Infoboxes, iv) From b3e31236128bad9a72c92c3cd0e37b26df226ea6 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 16:27:49 +0000 Subject: [PATCH 044/100] security: fix build errors, add honest Google UA, sanitize error msgs - Fix config validation: upstream URLs allow private IPs (self-hosted) - Fix util.SafeURLScheme to return parsed URL - Replace spoofed GSA User-Agent with honest Kafka UA - Sanitize all engine error messages (strip response bodies) - Replace unused body reads with io.Copy(io.Discard, ...) for reuse - Fix pre-existing braveapi_test using wrong struct type - Fix ratelimit test reference to limiter variable - Update ratelimit tests for new trusted proxy behavior --- internal/config/config.go | 6 +++--- internal/engines/arxiv.go | 2 +- internal/engines/bing.go | 2 +- internal/engines/brave.go | 2 +- internal/engines/braveapi.go | 2 +- internal/engines/braveapi_test.go | 2 +- internal/engines/crossref.go | 2 +- internal/engines/duckduckgo.go | 2 +- internal/engines/github.go | 2 +- internal/engines/google.go | 22 ++++++---------------- internal/engines/qwant.go | 4 ++-- internal/engines/reddit.go | 2 +- internal/engines/wikipedia.go | 2 +- internal/engines/youtube.go | 2 +- internal/middleware/ratelimit.go | 2 +- internal/middleware/ratelimit_test.go | 2 +- internal/util/validate.go | 12 ++++++++---- 17 files changed, 32 insertions(+), 38 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 42aac23..f5a8b9a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,7 +18,6 @@ package config import ( "fmt" - "log" "os" "strings" "time" @@ -144,10 +143,11 @@ func validateConfig(cfg *Config) error { } } if cfg.Upstream.URL != "" { - if err := util.ValidatePublicURL(cfg.Upstream.URL); err != nil { + // Validate scheme and well-formedness, but allow private IPs + // since self-hosted deployments commonly use localhost/internal addresses. + if _, err := util.SafeURLScheme(cfg.Upstream.URL); err != nil { return fmt.Errorf("upstream.url: %w", err) } - log.Printf("WARNING: upstream.url SSRF protection is enabled; ensure the upstream host is not on a private network") } return nil } diff --git a/internal/engines/arxiv.go b/internal/engines/arxiv.go index 111e3b7..2f9cca0 100644 --- a/internal/engines/arxiv.go +++ b/internal/engines/arxiv.go @@ -75,7 +75,7 @@ func (e *ArxivEngine) Search(ctx context.Context, req contracts.SearchRequest) ( defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 16*1024)) return contracts.SearchResponse{}, fmt.Errorf("arxiv upstream error: status %d", resp.StatusCode) } diff --git a/internal/engines/bing.go b/internal/engines/bing.go index b1abbab..3b18f7b 100644 --- a/internal/engines/bing.go +++ b/internal/engines/bing.go @@ -68,7 +68,7 @@ func (e *BingEngine) Search(ctx context.Context, req contracts.SearchRequest) (c defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) return contracts.SearchResponse{}, fmt.Errorf("bing upstream error: status %d", resp.StatusCode) } diff --git a/internal/engines/brave.go b/internal/engines/brave.go index 373bc47..da25630 100644 --- a/internal/engines/brave.go +++ b/internal/engines/brave.go @@ -45,7 +45,7 @@ func (e *BraveEngine) Search(ctx context.Context, req contracts.SearchRequest) ( defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) return contracts.SearchResponse{}, fmt.Errorf("brave error: status %d", resp.StatusCode) } diff --git a/internal/engines/braveapi.go b/internal/engines/braveapi.go index 6b89347..830b010 100644 --- a/internal/engines/braveapi.go +++ b/internal/engines/braveapi.go @@ -127,7 +127,7 @@ func (e *BraveAPIEngine) Search(ctx context.Context, req contracts.SearchRequest defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 16*1024)) return contracts.SearchResponse{}, fmt.Errorf("brave upstream error: status %d", resp.StatusCode) } diff --git a/internal/engines/braveapi_test.go b/internal/engines/braveapi_test.go index 13c7420..ed710ff 100644 --- a/internal/engines/braveapi_test.go +++ b/internal/engines/braveapi_test.go @@ -39,7 +39,7 @@ func TestBraveEngine_GatingAndHeader(t *testing.T) { }) client := &http.Client{Transport: transport} - engine := &BraveEngine{ + engine := &BraveAPIEngine{ client: client, apiKey: wantAPIKey, accessGateToken: wantToken, diff --git a/internal/engines/crossref.go b/internal/engines/crossref.go index d911034..79e6ab5 100644 --- a/internal/engines/crossref.go +++ b/internal/engines/crossref.go @@ -63,7 +63,7 @@ func (e *CrossrefEngine) Search(ctx context.Context, req contracts.SearchRequest defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 16*1024)) return contracts.SearchResponse{}, fmt.Errorf("crossref upstream error: status %d", resp.StatusCode) } diff --git a/internal/engines/duckduckgo.go b/internal/engines/duckduckgo.go index 9aa275e..7a71ef4 100644 --- a/internal/engines/duckduckgo.go +++ b/internal/engines/duckduckgo.go @@ -63,7 +63,7 @@ func (e *DuckDuckGoEngine) Search(ctx context.Context, req contracts.SearchReque defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) return contracts.SearchResponse{}, fmt.Errorf("duckduckgo upstream error: status %d", resp.StatusCode) } diff --git a/internal/engines/github.go b/internal/engines/github.go index 13d85b8..d0c9fcc 100644 --- a/internal/engines/github.go +++ b/internal/engines/github.go @@ -66,7 +66,7 @@ func (e *GitHubEngine) Search(ctx context.Context, req contracts.SearchRequest) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) return contracts.SearchResponse{}, fmt.Errorf("github api error: status %d", resp.StatusCode) } diff --git a/internal/engines/google.go b/internal/engines/google.go index 49e2dbe..cea4bd5 100644 --- a/internal/engines/google.go +++ b/internal/engines/google.go @@ -28,20 +28,10 @@ import ( "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 -} +// googleUserAgent is an honest User-Agent identifying the metasearch engine. +// Using a spoofed GSA User-Agent violates Google's Terms of Service and +// risks permanent IP blocking. +var googleUserAgent = "Kafka/0.1 (compatible; +https://github.com/metamorphosis-dev/kafka)" type GoogleEngine struct { client *http.Client @@ -70,7 +60,7 @@ func (e *GoogleEngine) Search(ctx context.Context, req contracts.SearchRequest) if err != nil { return contracts.SearchResponse{}, err } - httpReq.Header.Set("User-Agent", gsaUA()) + httpReq.Header.Set("User-Agent", googleUserAgent) httpReq.Header.Set("Accept", "*/*") httpReq.AddCookie(&http.Cookie{Name: "CONSENT", Value: "YES+"}) @@ -95,7 +85,7 @@ func (e *GoogleEngine) Search(ctx context.Context, req contracts.SearchRequest) } if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) return contracts.SearchResponse{}, fmt.Errorf("google error: status %d", resp.StatusCode) } diff --git a/internal/engines/qwant.go b/internal/engines/qwant.go index 1c4876b..7fa963b 100644 --- a/internal/engines/qwant.go +++ b/internal/engines/qwant.go @@ -124,7 +124,7 @@ func (e *QwantEngine) searchWebAPI(ctx context.Context, req contracts.SearchRequ } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 16*1024)) return contracts.SearchResponse{}, fmt.Errorf("qwant upstream error: status %d", resp.StatusCode) } @@ -253,7 +253,7 @@ func (e *QwantEngine) searchWebLite(ctx context.Context, req contracts.SearchReq defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 16*1024)) return contracts.SearchResponse{}, fmt.Errorf("qwant lite upstream error: status %d", resp.StatusCode) } diff --git a/internal/engines/reddit.go b/internal/engines/reddit.go index cb75cf9..699e7b2 100644 --- a/internal/engines/reddit.go +++ b/internal/engines/reddit.go @@ -62,7 +62,7 @@ func (e *RedditEngine) Search(ctx context.Context, req contracts.SearchRequest) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) return contracts.SearchResponse{}, fmt.Errorf("reddit api error: status %d", resp.StatusCode) } diff --git a/internal/engines/wikipedia.go b/internal/engines/wikipedia.go index 3a65749..518d994 100644 --- a/internal/engines/wikipedia.go +++ b/internal/engines/wikipedia.go @@ -134,7 +134,7 @@ func (e *WikipediaEngine) Search(ctx context.Context, req contracts.SearchReques }, nil } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 16*1024)) return contracts.SearchResponse{}, fmt.Errorf("wikipedia upstream error: status %d", resp.StatusCode) } diff --git a/internal/engines/youtube.go b/internal/engines/youtube.go index ec0add9..0c5ff9e 100644 --- a/internal/engines/youtube.go +++ b/internal/engines/youtube.go @@ -77,7 +77,7 @@ func (e *YouTubeEngine) Search(ctx context.Context, req contracts.SearchRequest) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) return contracts.SearchResponse{}, fmt.Errorf("youtube api error: status %d", resp.StatusCode) } diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go index 8bd1123..6f662fd 100644 --- a/internal/middleware/ratelimit.go +++ b/internal/middleware/ratelimit.go @@ -80,7 +80,7 @@ func RateLimit(cfg RateLimitConfig, logger *slog.Logger) func(http.Handler) http return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ip := l.extractIP(r) + ip := limiter.extractIP(r) if !limiter.allow(ip) { retryAfter := int(limiter.window.Seconds()) diff --git a/internal/middleware/ratelimit_test.go b/internal/middleware/ratelimit_test.go index 514d985..8366e57 100644 --- a/internal/middleware/ratelimit_test.go +++ b/internal/middleware/ratelimit_test.go @@ -1,6 +1,7 @@ package middleware import ( + "net" "net/http" "net/http/httptest" "testing" @@ -92,7 +93,6 @@ func TestRateLimit_DifferentIPs(t *testing.T) { } func TestRateLimit_XForwardedFor(t *testing.T) { - privateNet := mustParseCIDR("10.0.0.0/8") h := RateLimit(RateLimitConfig{ Requests: 1, Window: 10 * time.Second, diff --git a/internal/util/validate.go b/internal/util/validate.go index 2ea31cc..eb7d55e 100644 --- a/internal/util/validate.go +++ b/internal/util/validate.go @@ -14,13 +14,17 @@ import ( "strings" ) -// SafeURLScheme returns true if the URL uses an acceptable scheme (http or https). -func SafeURLScheme(raw string) bool { +// SafeURLScheme validates that a URL is well-formed and uses an acceptable scheme. +// Returns the parsed URL on success, or an error. +func SafeURLScheme(raw string) (*url.URL, error) { u, err := url.Parse(raw) if err != nil { - return false + return nil, err } - return u.Scheme == "http" || u.Scheme == "https" + if u.Scheme != "http" && u.Scheme != "https" { + return nil, fmt.Errorf("URL must use http or https, got %q", u.Scheme) + } + return u, nil } // IsPrivateIP returns true if the IP address is in a private, loopback, From a316763aca88a18405a3c46bcb4e0e8d2a73445c Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 16:38:03 +0000 Subject: [PATCH 045/100] fix(test): update CORS preflight test for deny-all default Empty CORSConfig now means no CORS headers, matching the security fix. Test explicitly configures an origin to test preflight behavior. --- internal/middleware/cors_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/middleware/cors_test.go b/internal/middleware/cors_test.go index 4f3f6c2..f24ed65 100644 --- a/internal/middleware/cors_test.go +++ b/internal/middleware/cors_test.go @@ -51,7 +51,7 @@ func TestCORS_SpecificOrigin(t *testing.T) { } func TestCORS_Preflight(t *testing.T) { - h := CORS(CORSConfig{})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := CORS(CORSConfig{AllowedOrigins: []string{"https://example.com"}})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("handler should not be called for preflight") })) @@ -100,6 +100,7 @@ func TestCORS_CustomMethodsAndHeaders(t *testing.T) { })(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) req := httptest.NewRequest("OPTIONS", "/search", nil) + req.Header.Set("Origin", "https://example.com") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) From 2b072e4de37fb14e0a6707a78bc92926eebe31aa Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 16:49:24 +0000 Subject: [PATCH 046/100] feat: add image search with Bing, DuckDuckGo, and Qwant engines Three new image search engines: - bing_images: Bing Images via RSS endpoint - ddg_images: DuckDuckGo Images via VQD API - qwant_images: Qwant Images via v3 search API Frontend: - Image grid layout with responsive columns - image_item template with thumbnail, title, and source metadata - Hover animations and lazy loading - Grid activates automatically when category=images Backend: - category=images routes to image engines via planner - Image engines registered in factory and engine allowlist - extractImgSrc helper for parsing thumbnail URLs from HTML - IsImageSearch flag on PageData for template layout switching --- internal/engines/bing_images.go | 123 ++++++++++++ internal/engines/ddg_images.go | 207 ++++++++++++++++++++ internal/engines/factory.go | 4 + internal/engines/html_helpers.go | 11 ++ internal/engines/planner.go | 18 +- internal/engines/qwant_images.go | 199 +++++++++++++++++++ internal/search/request_params.go | 2 + internal/views/static/css/kafka.css | 94 +++++++++ internal/views/templates/image_item.html | 15 ++ internal/views/templates/results_inner.html | 12 ++ internal/views/views.go | 6 +- 11 files changed, 687 insertions(+), 4 deletions(-) create mode 100644 internal/engines/bing_images.go create mode 100644 internal/engines/ddg_images.go create mode 100644 internal/engines/qwant_images.go create mode 100644 internal/views/templates/image_item.html diff --git a/internal/engines/bing_images.go b/internal/engines/bing_images.go new file mode 100644 index 0000000..002f947 --- /dev/null +++ b/internal/engines/bing_images.go @@ -0,0 +1,123 @@ +// kafka — a privacy-respecting metasearch engine +// Copyright (C) 2026-present metamorphosis-dev +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package engines + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/metamorphosis-dev/kafka/internal/contracts" +) + +// BingImagesEngine searches Bing Images via their public RSS endpoint. +type BingImagesEngine struct { + client *http.Client +} + +func (e *BingImagesEngine) Name() string { return "bing_images" } + +func (e *BingImagesEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) { + if e == nil || e.client == nil { + return contracts.SearchResponse{}, errors.New("bing_images engine not initialized") + } + q := strings.TrimSpace(req.Query) + if q == "" { + return contracts.SearchResponse{Query: req.Query}, nil + } + + offset := (req.Pageno - 1) * 10 + endpoint := fmt.Sprintf( + "https://www.bing.com/images/search?q=%s&count=10&offset=%d&format=rss", + url.QueryEscape(q), + offset, + ) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return contracts.SearchResponse{}, err + } + httpReq.Header.Set("User-Agent", "kafka/0.1 (compatible; +https://git.ashisgreat.xyz/penal-colony/kafka)") + + resp, err := e.client.Do(httpReq) + if err != nil { + return contracts.SearchResponse{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return contracts.SearchResponse{}, fmt.Errorf("bing_images upstream error: status %d", resp.StatusCode) + } + + return parseBingImagesRSS(resp.Body, req.Query) +} + +// parseBingImagesRSS parses Bing's RSS image search results. +// The description field contains HTML with an tag whose src is the +// thumbnail and whose enclosing tag links to the source page. +func parseBingImagesRSS(r io.Reader, query string) (contracts.SearchResponse, error) { + type bingImageItem struct { + Title string `xml:"title"` + Link string `xml:"link"` + Descrip string `xml:"description"` + } + + type rssFeed struct { + XMLName xml.Name `xml:"rss"` + Channel struct { + Items []bingImageItem `xml:"item"` + } `xml:"channel"` + } + + var rss rssFeed + if err := xml.NewDecoder(r).Decode(&rss); err != nil { + return contracts.SearchResponse{}, fmt.Errorf("bing_images RSS parse error: %w", err) + } + + results := make([]contracts.MainResult, 0, len(rss.Channel.Items)) + for _, item := range rss.Channel.Items { + if item.Link == "" { + continue + } + + // Extract thumbnail URL from the description HTML. + thumbnail := extractImgSrc(item.Descrip) + content := stripHTML(item.Descrip) + + linkPtr := item.Link + results = append(results, contracts.MainResult{ + Template: "images", + Title: item.Title, + Content: content, + URL: &linkPtr, + Thumbnail: thumbnail, + Engine: "bing_images", + Score: 0, + Category: "images", + Engines: []string{"bing_images"}, + }) + } + + return contracts.SearchResponse{ + Query: query, + NumberOfResults: len(results), + Results: results, + Answers: []map[string]any{}, + Corrections: []string{}, + Infoboxes: []map[string]any{}, + Suggestions: []string{}, + UnresponsiveEngines: [][2]string{}, + }, nil +} diff --git a/internal/engines/ddg_images.go b/internal/engines/ddg_images.go new file mode 100644 index 0000000..5764af4 --- /dev/null +++ b/internal/engines/ddg_images.go @@ -0,0 +1,207 @@ +// kafka — a privacy-respecting metasearch engine +// Copyright (C) 2026-present metamorphosis-dev +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package engines + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/metamorphosis-dev/kafka/internal/contracts" +) + +// DuckDuckGoImagesEngine searches DuckDuckGo Images via their vql API. +type DuckDuckGoImagesEngine struct { + client *http.Client +} + +func (e *DuckDuckGoImagesEngine) Name() string { return "ddg_images" } + +func (e *DuckDuckGoImagesEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) { + if e == nil || e.client == nil { + return contracts.SearchResponse{}, errors.New("ddg_images engine not initialized") + } + q := strings.TrimSpace(req.Query) + if q == "" { + return contracts.SearchResponse{Query: req.Query}, nil + } + + // Step 1: Get a VQD token from the initial search page. + vqd, err := e.getVQD(ctx, q) + if err != nil { + return contracts.SearchResponse{ + Query: req.Query, + UnresponsiveEngines: [][2]string{{"ddg_images", "vqd_fetch_failed"}}, + Results: []contracts.MainResult{}, + Answers: []map[string]any{}, + Corrections: []string{}, + Infoboxes: []map[string]any{}, + Suggestions: []string{}, + }, nil + } + + // Step 2: Fetch image results using the VQD token. + endpoint := fmt.Sprintf( + "https://duckduckgo.com/i.js?q=%s&kl=wt-wt&l=wt-wt&p=1&s=%d&vqd=%s", + url.QueryEscape(q), + (req.Pageno-1)*50, + url.QueryEscape(vqd), + ) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return contracts.SearchResponse{}, err + } + httpReq.Header.Set("User-Agent", "kafka/0.1 (compatible; +https://git.ashisgreat.xyz/penal-colony/kafka)") + httpReq.Header.Set("Referer", "https://duckduckgo.com/") + + resp, err := e.client.Do(httpReq) + if err != nil { + return contracts.SearchResponse{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + io.Copy(io.Discard, io.LimitReader(resp.Body, 16*1024)) + return contracts.SearchResponse{}, fmt.Errorf("ddg_images upstream error: status %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) + if err != nil { + return contracts.SearchResponse{}, err + } + + return parseDDGImages(body, req.Query) +} + +// getVQD fetches a VQD token from DuckDuckGo's search page. +func (e *DuckDuckGoImagesEngine) getVQD(ctx context.Context, query string) (string, error) { + endpoint := "https://duckduckgo.com/?q=" + url.QueryEscape(query) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", err + } + httpReq.Header.Set("User-Agent", "kafka/0.1 (compatible; +https://git.ashisgreat.xyz/penal-colony/kafka)") + + resp, err := e.client.Do(httpReq) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) + if err != nil { + return "", err + } + + // Extract VQD from the HTML: vqd='...' + vqd := extractVQD(string(body)) + if vqd == "" { + return "", fmt.Errorf("vqd token not found in response") + } + return vqd, nil +} + +// extractVQD extracts the VQD token from DuckDuckGo's HTML response. +func extractVQD(html string) string { + // Look for: vqd='...' or vqd="..." + for _, prefix := range []string{"vqd='", `vqd="`} { + idx := strings.Index(html, prefix) + if idx == -1 { + continue + } + start := idx + len(prefix) + end := start + for end < len(html) && html[end] != '\'' && html[end] != '"' { + end++ + } + if end > start { + return html[start:end] + } + } + return "" +} + +// ddgImageResult represents a single image result from DDG's JSON API. +type ddgImageResult struct { + Title string `json:"title"` + URL string `json:"url"` + Thumbnail string `json:"thumbnail"` + Image string `json:"image"` + Width int `json:"width"` + Height int `json:"height"` + Source string `json:"source"` +} + +func parseDDGImages(body []byte, query string) (contracts.SearchResponse, error) { + var results struct { + Results []ddgImageResult `json:"results"` + } + + if err := json.Unmarshal(body, &results); err != nil { + return contracts.SearchResponse{}, fmt.Errorf("ddg_images JSON parse error: %w", err) + } + + out := make([]contracts.MainResult, 0, len(results.Results)) + for _, img := range results.Results { + if img.URL == "" { + continue + } + + // Prefer the full image URL as thumbnail, fall back to the thumbnail field. + thumb := img.Image + if thumb == "" { + thumb = img.Thumbnail + } + + // Build a simple content string showing dimensions. + content := "" + if img.Width > 0 && img.Height > 0 { + content = strconv.Itoa(img.Width) + " × " + strconv.Itoa(img.Height) + } + if img.Source != "" { + if content != "" { + content += " — " + img.Source + } else { + content = img.Source + } + } + + urlPtr := img.URL + out = append(out, contracts.MainResult{ + Template: "images", + Title: img.Title, + Content: content, + URL: &urlPtr, + Thumbnail: thumb, + Engine: "ddg_images", + Score: 0, + Category: "images", + Engines: []string{"ddg_images"}, + }) + } + + return contracts.SearchResponse{ + Query: query, + NumberOfResults: len(out), + Results: out, + Answers: []map[string]any{}, + Corrections: []string{}, + Infoboxes: []map[string]any{}, + Suggestions: []string{}, + UnresponsiveEngines: [][2]string{}, + }, nil +} diff --git a/internal/engines/factory.go b/internal/engines/factory.go index 68f66eb..c3a0d95 100644 --- a/internal/engines/factory.go +++ b/internal/engines/factory.go @@ -73,5 +73,9 @@ func NewDefaultPortedEngines(client *http.Client, cfg *config.Config) map[string apiKey: youtubeAPIKey, baseURL: "https://www.googleapis.com", }, + // Image engines + "bing_images": &BingImagesEngine{client: client}, + "ddg_images": &DuckDuckGoImagesEngine{client: client}, + "qwant_images": &QwantImagesEngine{client: client}, } } diff --git a/internal/engines/html_helpers.go b/internal/engines/html_helpers.go index 66690c3..c704d7c 100644 --- a/internal/engines/html_helpers.go +++ b/internal/engines/html_helpers.go @@ -72,3 +72,14 @@ func htmlUnescape(s string) string { s = strings.ReplaceAll(s, " ", " ") return s } + +// extractImgSrc finds the first in an HTML string and returns +// the src attribute value. +func extractImgSrc(html string) string { + idx := strings.Index(html, "= 300 { + io.Copy(io.Discard, io.LimitReader(resp.Body, 16*1024)) + return contracts.SearchResponse{}, fmt.Errorf("qwant_images upstream error: status %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) + if err != nil { + return contracts.SearchResponse{}, err + } + + return parseQwantImages(body, req.Query) +} + +func parseQwantImages(body []byte, query string) (contracts.SearchResponse, error) { + var top map[string]any + if err := json.Unmarshal(body, &top); err != nil { + return contracts.SearchResponse{}, fmt.Errorf("qwant_images JSON parse error: %w", err) + } + + status, _ := top["status"].(string) + if status != "success" { + return contracts.SearchResponse{ + Query: query, + UnresponsiveEngines: [][2]string{{"qwant_images", "api_error"}}, + Results: []contracts.MainResult{}, + Answers: []map[string]any{}, + Corrections: []string{}, + Infoboxes: []map[string]any{}, + Suggestions: []string{}, + }, nil + } + + data, _ := top["data"].(map[string]any) + result, _ := data["result"].(map[string]any) + items, _ := result["items"].(map[string]any) + mainline := items["mainline"] + + rows := toSlice(mainline) + if len(rows) == 0 { + return contracts.SearchResponse{ + Query: query, + NumberOfResults: 0, + Results: []contracts.MainResult{}, + Answers: []map[string]any{}, + Corrections: []string{}, + Infoboxes: []map[string]any{}, + Suggestions: []string{}, + UnresponsiveEngines: [][2]string{}, + }, nil + } + + out := make([]contracts.MainResult, 0) + for _, row := range rows { + rowMap, ok := row.(map[string]any) + if !ok { + continue + } + rowType, _ := rowMap["type"].(string) + if rowType != "images" { + continue + } + + rowItems := toSlice(rowMap["items"]) + for _, it := range rowItems { + itemMap, ok := it.(map[string]any) + if !ok { + continue + } + title := toString(itemMap["title"]) + resURL := toString(itemMap["url"]) + thumb := toString(itemMap["thumbnail"]) + fullImg := toString(itemMap["media"]) + source := toString(itemMap["source"]) + + if resURL == "" && fullImg == "" { + continue + } + + // Use the source page URL for the link, full image for thumbnail display. + linkPtr := resURL + if linkPtr == "" { + linkPtr = fullImg + } + displayThumb := fullImg + if displayThumb == "" { + displayThumb = thumb + } + + content := source + if width, ok := itemMap["width"]; ok { + w := toString(width) + if h, ok2 := itemMap["height"]; ok2 { + h2 := toString(h) + if w != "" && h2 != "" { + content = w + " × " + h2 + if source != "" { + content += " — " + source + } + } + } + } + + out = append(out, contracts.MainResult{ + Template: "images", + Title: title, + Content: content, + URL: &linkPtr, + Thumbnail: displayThumb, + Engine: "qwant_images", + Score: 0, + Category: "images", + Engines: []string{"qwant_images"}, + }) + } + } + + return contracts.SearchResponse{ + Query: query, + NumberOfResults: len(out), + Results: out, + Answers: []map[string]any{}, + Corrections: []string{}, + Infoboxes: []map[string]any{}, + Suggestions: []string{}, + UnresponsiveEngines: [][2]string{}, + }, nil +} diff --git a/internal/search/request_params.go b/internal/search/request_params.go index 2e477fb..a7c810d 100644 --- a/internal/search/request_params.go +++ b/internal/search/request_params.go @@ -35,6 +35,8 @@ var knownEngineNames = map[string]bool{ "braveapi": true, "brave": true, "qwant": true, "duckduckgo": true, "github": true, "reddit": true, "bing": true, "google": true, "youtube": true, + // Image engines + "bing_images": true, "ddg_images": true, "qwant_images": true, } // validateEngines filters engine names against the known registry. diff --git a/internal/views/static/css/kafka.css b/internal/views/static/css/kafka.css index 40f0f25..ac740d8 100644 --- a/internal/views/static/css/kafka.css +++ b/internal/views/static/css/kafka.css @@ -952,6 +952,100 @@ footer a:hover { } } +/* ============================================================ + Image Results + ============================================================ */ + +.image-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1rem; +} + +.image-result { + display: block; + border-radius: var(--radius-sm); + overflow: hidden; + background: var(--bg-secondary); + border: 1px solid var(--border); + transition: transform 0.15s ease, box-shadow 0.15s ease; + text-decoration: none; + color: inherit; +} + +.image-result:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px var(--shadow); +} + +.image-result:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.image-thumb { + aspect-ratio: 1; + overflow: hidden; + background: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; +} + +.image-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform 0.2s ease; +} + +.image-result:hover .image-thumb img { + transform: scale(1.05); +} + +.image-thumb.image-error img, +.image-thumb.image-error { + display: none; +} + +.image-placeholder { + font-size: 2rem; + opacity: 0.3; +} + +.image-meta { + padding: 0.5rem; + min-height: 2.5rem; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.image-title { + font-size: 0.8rem; + font-weight: 500; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.image-source { + font-size: 0.7rem; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 480px) { + .image-grid { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 0.5rem; + } +} + /* ============================================================ Infoboxes ============================================================ */ diff --git a/internal/views/templates/image_item.html b/internal/views/templates/image_item.html new file mode 100644 index 0000000..ac067ce --- /dev/null +++ b/internal/views/templates/image_item.html @@ -0,0 +1,15 @@ +{{define "image_item"}} + +
+ {{if .Thumbnail}} + {{.Title}} + {{else}} +
🖼️
+ {{end}} +
+
+ {{.Title}} + {{if .Content}}{{.Content}}{{end}} +
+
+{{end}} diff --git a/internal/views/templates/results_inner.html b/internal/views/templates/results_inner.html index cca51d1..5dc67b5 100644 --- a/internal/views/templates/results_inner.html +++ b/internal/views/templates/results_inner.html @@ -19,13 +19,25 @@
{{if .Results}} + {{if .IsImageSearch}} +
+ {{range .Results}} + {{if eq .Template "images"}} + {{template "image_item" .}} + {{end}} + {{end}} +
+ {{else}} {{range .Results}} {{if eq .Template "videos"}} {{template "video_item" .}} + {{else if eq .Template "images"}} + {{template "image_item" .}} {{else}} {{template "result_item" .}} {{end}} {{end}} + {{end}} {{else if not .Answers}}
🔍
diff --git a/internal/views/views.go b/internal/views/views.go index c176f81..4162a03 100644 --- a/internal/views/views.go +++ b/internal/views/views.go @@ -52,6 +52,7 @@ type PageData struct { UnresponsiveEngines [][2]string PageNumbers []PageNumber ShowHeader bool + IsImageSearch bool // New fields for three-column layout Categories []string CategoryIcons map[string]string @@ -106,13 +107,13 @@ func init() { } tmplFull = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, - "base.html", "results.html", "results_inner.html", "result_item.html", "video_item.html", + "base.html", "results.html", "results_inner.html", "result_item.html", "video_item.html", "image_item.html", )) tmplIndex = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, "base.html", "index.html", )) tmplFragment = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, - "results.html", "results_inner.html", "result_item.html", "video_item.html", + "results.html", "results_inner.html", "result_item.html", "video_item.html", "image_item.html", )) tmplPreferences = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, "base.html", "preferences.html", @@ -168,6 +169,7 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int, activ "weather": "🌤️", }, ActiveCategory: activeCategory, + IsImageSearch: activeCategory == "images", // Time filters TimeFilters: []FilterOption{ From a9ae69cad5c479b1b42e9d20cb856006a57f5e18 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 17:22:31 +0000 Subject: [PATCH 047/100] fix(security): allow HTMX CDN and inline scripts in CSP script-src now permits 'unsafe-inline' and https://unpkg.com so the autocomplete script and HTMX library load correctly. --- internal/middleware/security.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/middleware/security.go b/internal/middleware/security.go index 09f3878..2d75003 100644 --- a/internal/middleware/security.go +++ b/internal/middleware/security.go @@ -80,7 +80,7 @@ func SecurityHeaders(cfg SecurityHeadersConfig) func(http.Handler) http.Handler func defaultCSP() string { return strings.Join([]string{ "default-src 'self'", - "script-src 'self'", + "script-src 'self' 'unsafe-inline' https://unpkg.com", "style-src 'self' 'unsafe-inline'", "img-src 'self' https: data:", "connect-src 'self'", From 2f10f4e1e59b86c046714c630d908ae0b23f0a84 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 17:31:06 +0000 Subject: [PATCH 048/100] fix(css): remove duplicate .results-layout that broke 3-column grid The old 3-column layout block (referencing .left-sidebar/.right-sidebar classes that don't exist in the HTML) was overriding the correct layout defined earlier. Removed the stale duplicate. --- internal/views/static/css/kafka.css | 44 ----------------------------- 1 file changed, 44 deletions(-) diff --git a/internal/views/static/css/kafka.css b/internal/views/static/css/kafka.css index ac740d8..ef318d0 100644 --- a/internal/views/static/css/kafka.css +++ b/internal/views/static/css/kafka.css @@ -1130,50 +1130,6 @@ a:focus-visible { background: var(--border-focus); } -/* ============================================================ - Three-Column Results Layout - ============================================================ */ - -.results-layout { - display: grid; - grid-template-columns: 200px 1fr 240px; - gap: 2rem; - align-items: start; -} - -.results-layout .left-sidebar, -.results-layout .right-sidebar { - position: sticky; - top: calc(var(--header-height) + 1.5rem); - max-height: calc(100vh - var(--header-height) - 3rem); - overflow-y: auto; -} - -.results-layout .results-column { - min-width: 0; -} - -/* Tablet: hide left sidebar, two columns */ -@media (min-width: 769px) and (max-width: 1024px) { - .results-layout { - grid-template-columns: 1fr 220px; - } - .results-layout .left-sidebar { - display: none; - } -} - -/* Mobile: single column, no sidebars */ -@media (max-width: 768px) { - .results-layout { - grid-template-columns: 1fr; - } - .results-layout .left-sidebar, - .results-layout .right-sidebar { - display: none; - } -} - /* ============================================================ Preferences Page Layout ============================================================ */ From 00b2be9e796fe666c3cf22046b4764777f97e123 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 17:35:35 +0000 Subject: [PATCH 049/100] fix(css): restore original layout, re-add only image grid styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverted CSS to the known-working state at 4b0cde9, then re-applied only the image grid styles. The duplicate .results-layout block is intentional — it was present in the working version too. --- internal/views/static/css/kafka.css | 44 +++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/internal/views/static/css/kafka.css b/internal/views/static/css/kafka.css index ef318d0..ac740d8 100644 --- a/internal/views/static/css/kafka.css +++ b/internal/views/static/css/kafka.css @@ -1130,6 +1130,50 @@ a:focus-visible { background: var(--border-focus); } +/* ============================================================ + Three-Column Results Layout + ============================================================ */ + +.results-layout { + display: grid; + grid-template-columns: 200px 1fr 240px; + gap: 2rem; + align-items: start; +} + +.results-layout .left-sidebar, +.results-layout .right-sidebar { + position: sticky; + top: calc(var(--header-height) + 1.5rem); + max-height: calc(100vh - var(--header-height) - 3rem); + overflow-y: auto; +} + +.results-layout .results-column { + min-width: 0; +} + +/* Tablet: hide left sidebar, two columns */ +@media (min-width: 769px) and (max-width: 1024px) { + .results-layout { + grid-template-columns: 1fr 220px; + } + .results-layout .left-sidebar { + display: none; + } +} + +/* Mobile: single column, no sidebars */ +@media (max-width: 768px) { + .results-layout { + grid-template-columns: 1fr; + } + .results-layout .left-sidebar, + .results-layout .right-sidebar { + display: none; + } +} + /* ============================================================ Preferences Page Layout ============================================================ */ From 1543b166052589afd138f551d878deec18d3b72b Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 18:58:50 +0100 Subject: [PATCH 050/100] docs: add frontend replacement design spec Co-Authored-By: Claude Opus 4.6 --- .../2026-03-22-frontend-replacement-design.md | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-22-frontend-replacement-design.md diff --git a/docs/superpowers/specs/2026-03-22-frontend-replacement-design.md b/docs/superpowers/specs/2026-03-22-frontend-replacement-design.md new file mode 100644 index 0000000..65c294a --- /dev/null +++ b/docs/superpowers/specs/2026-03-22-frontend-replacement-design.md @@ -0,0 +1,74 @@ +# Frontend Replacement: search-zen-50 Integration + +## Status +Approved + +## Overview + +Replace the current Go template-based frontend (HTMX + Go templates) with the search-zen-50 React SPA. The React app is built statically and embedded into the Go binary, serving as a single binary deployment. + +## Architecture + +- **Build**: React/Vite app builds to `dist/` directory +- **Embed**: Go's `//go:embed` embeds the dist folder into the binary +- **Serve**: Go HTTP server serves static files and handles API routes +- **SPA routing**: Non-API routes serve `index.html` for React Router + +## Changes + +### Go Side + +1. **Create `internal/spa/spa.go`** + - Embeds the React build (`dist/`) using `//go:embed` + - Serves static files (JS, CSS, images) + - Handles SPA fallback: serves `index.html` for all non-API routes + - Provides `SPAHandler` that wraps API routes + +2. **Modify `cmd/kafka/main.go`** + - Import the embedded SPA files + - Route `/`, `/preferences`, and unknown routes to SPA handler + - Keep existing API routes: `/search`, `/autocompleter`, `/healthz`, `/opensearch.xml` + +### React Side + +1. **Modify `use-search.ts`** + - Replace mock data with real API call: `fetch("/search?format=json&q=${encodeURIComponent(query)}")` + - Map response to existing `SearXNGResponse` type (already matches) + +2. **Add autocomplete** (optional enhancement) + - Call `/autocompleter?q=${encodeURIComponent(query)}` + - Display suggestions while typing + +3. **Keep unchanged** + - All UI components + - Preferences page (localStorage-based) + - Routing (React Router) + +## Data Flow + +``` +Browser → GET / → Go serves embedded index.html +Browser → GET /search?format=json&q=... → Go search handler → JSON +Browser → React renders results via use-search hook +``` + +## API Compatibility + +The existing kafka API (`/search?format=json`) already matches the expected `SearXNGResponse` interface in the React code: +- `query: string` +- `number_of_results: number` +- `results: SearchResult[]` +- `suggestions: string[]` +- `unresponsive_engines: string[][]` + +## File Changes + +- **New**: `internal/spa/spa.go` +- **Modified**: `cmd/kafka/main.go` (wire SPA handler) +- **Modified**: `src/hooks/use-search.ts` (use real API) +- **Build step**: `npm run build` or `bun run build` in search-zen-50 + +## Dependencies + +- React app uses `@tanstack/react-query` for API calls (already in package.json) +- No new Go dependencies needed From 8651183540fb63c9c39ccc9060dc7590540e1081 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 19:40:34 +0100 Subject: [PATCH 051/100] feat(spa): add SPA Go package with embedded dist FS Creates internal/spa package that: - Embeds React build output from cmd/kafka/dist/ - Provides HTTP handler for static file serving - Falls back to index.html for SPA client-side routing Co-Authored-By: Claude Opus 4.6 --- internal/spa/spa.go | 56 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 internal/spa/spa.go diff --git a/internal/spa/spa.go b/internal/spa/spa.go new file mode 100644 index 0000000..b9810dd --- /dev/null +++ b/internal/spa/spa.go @@ -0,0 +1,56 @@ +package spa + +import ( + "embed" + "io/fs" + "net/http" + "path" +) + +//go:embed all:dist +var distFS embed.FS + +// DistFS returns the embedded dist directory as an fs.FS. +func DistFS() (fs.FS, error) { + return fs.Sub(distFS, "dist") +} + +// NewHandler returns an HTTP handler that: +// - Serves static files from the embedded dist/ directory +// - Falls back to index.html for SPA routing (any non-API path) +func NewHandler() http.Handler { + dist, err := DistFS() + if err != nil { + panic("spa: embedded dist not found: " + err.Error()) + } + return &spaHandler{dist: dist} +} + +type spaHandler struct { + dist fs.FS +} + +func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // API paths are handled by Go API handlers - this should never be reached + // since Go mux dispatches to specific handlers first. But if reached, + // pass through to FileServer which will return 404 for unknown paths. + + // Try to serve the requested file first + filePath := path.Clean(r.URL.Path) + f, err := h.dist.Open(filePath) + if err == nil { + f.Close() + // File exists - serve it via FileServer + http.FileServer(http.FS(h.dist)).ServeHTTP(w, r) + return + } + + // Fallback to index.html for SPA routing + indexFile, err := h.dist.Open("index.html") + if err != nil { + http.Error(w, "index.html not found in embedded files", http.StatusInternalServerError) + return + } + indexFile.Close() + http.FileServer(http.FS(h.dist)).ServeHTTP(w, r) +} \ No newline at end of file From 5d14d291ca92e6b23776fbf27b1509d2a7f84772 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 19:50:03 +0100 Subject: [PATCH 052/100] feat(main): wire SPA handler in main.go Replace template-based handlers (h.Index, h.Preferences) with the new spa handler. API routes (healthz, search, autocompleter, opensearch.xml) are registered first as exact matches, followed by the SPA catchall handler for all other routes. Remove unused views and io/fs imports. Co-Authored-By: Claude Opus 4.6 --- cmd/kafka/main.go | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/cmd/kafka/main.go b/cmd/kafka/main.go index f691665..29ab620 100644 --- a/cmd/kafka/main.go +++ b/cmd/kafka/main.go @@ -19,7 +19,6 @@ package main import ( "flag" "fmt" - "io/fs" "log" "log/slog" "net/http" @@ -31,7 +30,7 @@ import ( "github.com/metamorphosis-dev/kafka/internal/httpapi" "github.com/metamorphosis-dev/kafka/internal/middleware" "github.com/metamorphosis-dev/kafka/internal/search" - "github.com/metamorphosis-dev/kafka/internal/views" + "github.com/metamorphosis-dev/kafka/internal/spa" ) func main() { @@ -80,20 +79,16 @@ func main() { h := httpapi.NewHandler(svc, acSvc.Suggestions, cfg.Server.SourceURL) mux := http.NewServeMux() - mux.HandleFunc("/", h.Index) + + // API routes - handled by Go mux.HandleFunc("/healthz", h.Healthz) mux.HandleFunc("/search", h.Search) mux.HandleFunc("/autocompleter", h.Autocompleter) - mux.HandleFunc("/preferences", h.Preferences) mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL)) - // Serve embedded static files (CSS, JS, images). - staticFS, err := views.StaticFS() - if err != nil { - log.Fatalf("failed to load static files: %v", err) - } - var subFS fs.FS = staticFS - mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(subFS)))) + // SPA handler - serves React app for all other routes + spaHandler := spa.NewHandler() + mux.Handle("/", spaHandler) // Apply middleware: global rate limit → burst rate limit → per-IP rate limit → CORS → security headers → handler. var handler http.Handler = mux From 6b418057ef5e1849f8b57f109751c0eeb279527d Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 21:12:01 +0100 Subject: [PATCH 053/100] feat(frontend): replace Go templates with React SPA - Add internal/spa package for embedding React build - Wire SPA handler in main.go for non-API routes - Add gitignore entry for internal/spa/dist - Add implementation plan Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + .../plans/2026-03-22-frontend-replacement.md | 358 ++++++++++++++++++ 2 files changed, 359 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-22-frontend-replacement.md diff --git a/.gitignore b/.gitignore index a5388c7..19776c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ .agent/ +internal/spa/dist/ *.exe *.exe~ *.dll diff --git a/docs/superpowers/plans/2026-03-22-frontend-replacement.md b/docs/superpowers/plans/2026-03-22-frontend-replacement.md new file mode 100644 index 0000000..1cb475e --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-frontend-replacement.md @@ -0,0 +1,358 @@ +# Frontend Replacement 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:** Replace the Go template-based frontend with the search-zen-50 React SPA, embedded in the Go binary as a single deployment. + +**Architecture:** Build React app → embed in Go binary via `//go:embed` → serve via Go HTTP server with SPA fallback routing. React calls `/search?format=json` and `/autocompleter?q=` APIs. + +**Tech Stack:** Go (embed), React 18, Vite, TailwindCSS, React Router, @tanstack/react-query + +--- + +## File Map + +| File | Action | +|------|--------| +| `cmd/kafka/main.go` | Modify - replace template handlers with SPA handler | +| `internal/spa/spa.go` | Create - embed React build, serve static files, SPA fallback | +| `internal/spa/dist/` | Build output - React build artifacts (gitignored) | +| `src/hooks/use-search.ts` | Modify - replace mock with real API calls | +| `src/lib/mock-data.ts` | Keep types, remove MOCK_RESPONSE usage | + +--- + +## Task 1: Build React App + +**Files:** +- Build: `/tmp/search-zen-50/dist/` (output directory) + +- [ ] **Step 1: Install dependencies and build** + +```bash +cd /tmp/search-zen-50 && bun install && bun run build +``` + +Expected: `dist/` directory created with `index.html`, `assets/` folder containing JS/CSS bundles + +- [ ] **Step 2: Verify dist contents** + +```bash +ls /tmp/search-zen-50/dist/ && ls /tmp/search-zen-50/dist/assets/ | head -10 +``` + +Expected: `index.html` exists, `assets/` contains `.js` and `.css` files + +--- + +## Task 2: Create SPA Go Package + +**Files:** +- Create: `internal/spa/spa.go` + +```go +package spa + +import ( + "embed" + "io/fs" + "net/http" + "path" +) + +//go:embed all:dist +var distFS embed.FS + +// DistFS returns the embedded dist directory as an fs.FS. +func DistFS() (fs.FS, error) { + return fs.Sub(distFS, "dist") +} + +// NewHandler returns an HTTP handler that: +// - Serves static files from the embedded dist/ directory +// - Falls back to index.html for SPA routing (any non-API path) +func NewHandler() http.Handler { + dist, err := DistFS() + if err != nil { + panic("spa: embedded dist not found: " + err.Error()) + } + return &spaHandler{dist: dist} +} + +type spaHandler struct { + dist fs.FS +} + +func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // API paths are handled by Go API handlers - this should never be reached + // since Go mux dispatches to specific handlers first. But if reached, + // pass through to FileServer which will return 404 for unknown paths. + + // Try to serve the requested file first + filePath := path.Clean(r.URL.Path) + f, err := h.dist.Open(filePath) + if err == nil { + f.Close() + // File exists - serve it via FileServer + http.FileServer(http.FS(h.dist)).ServeHTTP(w, r) + return + } + + // Fallback to index.html for SPA routing + indexFile, err := h.dist.Open("index.html") + if err != nil { + http.Error(w, "index.html not found in embedded files", http.StatusInternalServerError) + return + } + indexFile.Close() + http.FileServer(http.FS(h.dist)).ServeHTTP(w, r) +} + +``` + +--- + +## Task 3: Wire SPA Handler in main.go + +**Files:** +- Modify: `cmd/kafka/main.go` + +- [ ] **Step 1: Replace handlers with SPA** + +In `main.go`, find and replace the `mux.HandleFunc` section (lines 82-88) and the static file serving section (lines 90-96). + +Old code (lines 82-96): +```go +mux := http.NewServeMux() +mux.HandleFunc("/", h.Index) +mux.HandleFunc("/healthz", h.Healthz) +mux.HandleFunc("/search", h.Search) +mux.HandleFunc("/autocompleter", h.Autocompleter) +mux.HandleFunc("/preferences", h.Preferences) +mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL)) + +// Serve embedded static files (CSS, JS, images). +staticFS, err := views.StaticFS() +if err != nil { + log.Fatalf("failed to load static files: %v", err) +} +var subFS fs.FS = staticFS +mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(subFS)))) +``` + +New code: +```go +mux := http.NewServeMux() + +// API routes - handled by Go +mux.HandleFunc("/healthz", h.Healthz) +mux.HandleFunc("/search", h.Search) +mux.HandleFunc("/autocompleter", h.Autocompleter) +mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL)) + +// SPA handler - serves React app for all other routes +spaHandler := spa.NewHandler() +mux.Handle("/", spaHandler) +``` + +- [ ] **Step 2: Add spa import** + +Add to imports (after `"github.com/metamorphosis-dev/kafka/internal/search"`): +```go +"github.com/metamorphosis-dev/kafka/internal/spa" +``` + +- [ ] **Step 3: Remove unused views import if needed** + +If `views` is only used for `StaticFS()`, remove the import. The template rendering functions (`RenderIndex`, etc.) won't be needed anymore. + +- [ ] **Step 4: Verify build** + +```bash +cd /home/ashie/git/kafka && go build ./cmd/kafka/ +``` + +Expected: Builds successfully (may fail on embed if dist not found - continue to next task) + +--- + +## Task 4: Wire React to Real API + +**Files:** +- Modify: `src/hooks/use-search.ts` in `/tmp/search-zen-50/` + +- [ ] **Step 1: Replace mock search with real API call** + +Replace the `search` function in `use-search.ts`: + +Old code (lines 23-36): +```typescript +const search = useCallback(async (query: string) => { + if (!query.trim()) return; + + setState((prev) => ({ ...prev, query, isLoading: true, error: null, hasSearched: true })); + + // Simulate network delay + await new Promise((r) => setTimeout(r, 800)); + + setState((prev) => ({ + ...prev, + isLoading: false, + results: { ...MOCK_RESPONSE, query }, + })); +}, []); +``` + +New code: +```typescript +const search = useCallback(async (query: string) => { + if (!query.trim()) return; + + setState((prev) => ({ ...prev, query, isLoading: true, error: null, hasSearched: true })); + + try { + const response = await fetch(`/search?format=json&q=${encodeURIComponent(query)}`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + setState((prev) => ({ + ...prev, + isLoading: false, + results: data, + })); + } catch (err) { + setState((prev) => ({ + ...prev, + isLoading: false, + error: err instanceof Error ? err.message : "Search failed", + })); + } +}, []); +``` + +- [ ] **Step 2: Remove mock data import** + +Remove the mock import line (should be near line 2): +```typescript +import { MOCK_RESPONSE, type SearXNGResponse, type Category } from "@/lib/mock-data"; +``` + +Replace with: +```typescript +import type { SearXNGResponse, Category } from "@/lib/mock-data"; +``` + +- [ ] **Step 3: Keep the CATEGORIES export** + +Ensure `mock-data.ts` still exports `CATEGORIES` and `Category` type. The file should look like: + +```typescript +// Keep these exports - used by CategoryTabs and preferences +export const CATEGORIES = ["general", "it", "images", "news"] as const; +export type Category = typeof CATEGORIES[number]; + +// Keep interfaces +export interface SearchResult { + url: string; + title: string; + content: string; + engine: string; + parsed_url: [string, string, string, string, string]; + engines: string[]; + positions: number[]; + score: number; + category: string; + pretty_url: string; + img_src?: string; + thumbnail?: string; + publishedDate?: string; +} + +export interface SearXNGResponse { + query: string; + number_of_results: number; + results: SearchResult[]; + answers: string[]; + corrections: string[]; + infoboxes: any[]; + suggestions: string[]; + unresponsive_engines: string[]; +} +``` + +--- + +## Task 5: Rebuild React and Verify + +**Files:** +- Build: `/tmp/search-zen-50/dist/` + +- [ ] **Step 1: Rebuild with changes** + +```bash +cd /tmp/search-zen-50 && bun run build +``` + +- [ ] **Step 2: Copy dist to kafka** + +```bash +rm -rf /home/ashie/git/kafka/internal/spa/dist +cp -r /tmp/search-zen-50/dist /home/ashie/git/kafka/internal/spa/dist +``` + +- [ ] **Step 3: Verify Go build** + +```bash +cd /home/ashie/git/kafka && go build ./cmd/kafka/ && echo "Build successful" +``` + +Expected: "Build successful" + +--- + +## Task 6: Test the Integration + +- [ ] **Step 1: Start the server** + +```bash +cd /home/ashie/git/kafka && ./kafka -config config.toml & +sleep 2 +``` + +- [ ] **Step 2: Test homepage** + +```bash +curl -s http://localhost:8080/ | head -20 +``` + +Expected: HTML with `
` from React app + +- [ ] **Step 3: Test API** + +```bash +curl -s "http://localhost:8080/search?format=json&q=test" | head -50 +``` + +Expected: JSON search response + +- [ ] **Step 4: Clean up** + +```bash +pkill -f "./kafka" 2>/dev/null; echo "Done" +``` + +--- + +## Dependencies + +- Node.js/Bun for building React app +- Go 1.24+ for embed functionality +- No new Go dependencies + +## Notes + +- The `internal/spa/dist/` folder should be gitignored (build artifact) +- The `internal/spa/dist/` copy is needed for the embed to work at compile time +- Preferences page is entirely client-side (localStorage) - no backend needed +- Autocomplete can be added later by modifying `SearchInput.tsx` to call `/autocompleter` From 168cb78fab5cbe9785ae99fa7d52e544c620158a Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 21:27:45 +0100 Subject: [PATCH 054/100] feat: add frontend source code Add search-zen-50 React SPA source code to frontend/ directory. Build artifacts (dist, node_modules, lock files) are gitignored. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 5 + frontend/.gitignore | 24 + frontend/README.md | 3 + frontend/components.json | 20 + frontend/eslint.config.js | 26 + frontend/index.html | 29 + frontend/package.json | 90 +++ frontend/playwright-fixture.ts | 3 + frontend/playwright.config.ts | 10 + frontend/postcss.config.js | 6 + frontend/public/favicon.ico | Bin 0 -> 20373 bytes frontend/public/placeholder.svg | 40 ++ frontend/public/robots.txt | 14 + frontend/src/App.css | 42 ++ frontend/src/App.tsx | 31 + frontend/src/components/CategoryTabs.tsx | 39 ++ frontend/src/components/NavLink.tsx | 28 + frontend/src/components/ResultCard.tsx | 40 ++ frontend/src/components/ResultSkeleton.tsx | 19 + frontend/src/components/SearchInput.tsx | 43 ++ frontend/src/components/ui/accordion.tsx | 52 ++ frontend/src/components/ui/alert-dialog.tsx | 104 +++ frontend/src/components/ui/alert.tsx | 43 ++ frontend/src/components/ui/aspect-ratio.tsx | 5 + frontend/src/components/ui/avatar.tsx | 38 ++ frontend/src/components/ui/badge.tsx | 29 + frontend/src/components/ui/breadcrumb.tsx | 90 +++ frontend/src/components/ui/button.tsx | 47 ++ frontend/src/components/ui/calendar.tsx | 54 ++ frontend/src/components/ui/card.tsx | 43 ++ frontend/src/components/ui/carousel.tsx | 224 ++++++ frontend/src/components/ui/chart.tsx | 303 +++++++++ frontend/src/components/ui/checkbox.tsx | 26 + frontend/src/components/ui/collapsible.tsx | 9 + frontend/src/components/ui/command.tsx | 132 ++++ frontend/src/components/ui/context-menu.tsx | 178 +++++ frontend/src/components/ui/dialog.tsx | 95 +++ frontend/src/components/ui/drawer.tsx | 87 +++ frontend/src/components/ui/dropdown-menu.tsx | 179 +++++ frontend/src/components/ui/form.tsx | 129 ++++ frontend/src/components/ui/hover-card.tsx | 27 + frontend/src/components/ui/input-otp.tsx | 61 ++ frontend/src/components/ui/input.tsx | 22 + frontend/src/components/ui/label.tsx | 17 + frontend/src/components/ui/menubar.tsx | 207 ++++++ .../src/components/ui/navigation-menu.tsx | 120 ++++ frontend/src/components/ui/pagination.tsx | 81 +++ frontend/src/components/ui/popover.tsx | 29 + frontend/src/components/ui/progress.tsx | 23 + frontend/src/components/ui/radio-group.tsx | 36 + frontend/src/components/ui/resizable.tsx | 37 + frontend/src/components/ui/scroll-area.tsx | 38 ++ frontend/src/components/ui/select.tsx | 143 ++++ frontend/src/components/ui/separator.tsx | 20 + frontend/src/components/ui/sheet.tsx | 107 +++ frontend/src/components/ui/sidebar.tsx | 637 ++++++++++++++++++ frontend/src/components/ui/skeleton.tsx | 7 + frontend/src/components/ui/slider.tsx | 23 + frontend/src/components/ui/sonner.tsx | 27 + frontend/src/components/ui/switch.tsx | 27 + frontend/src/components/ui/table.tsx | 72 ++ frontend/src/components/ui/tabs.tsx | 53 ++ frontend/src/components/ui/textarea.tsx | 21 + frontend/src/components/ui/toast.tsx | 111 +++ frontend/src/components/ui/toaster.tsx | 24 + frontend/src/components/ui/toggle-group.tsx | 49 ++ frontend/src/components/ui/toggle.tsx | 37 + frontend/src/components/ui/tooltip.tsx | 28 + frontend/src/components/ui/use-toast.ts | 3 + frontend/src/contexts/PreferencesContext.tsx | 67 ++ frontend/src/hooks/use-mobile.tsx | 19 + frontend/src/hooks/use-search.ts | 72 ++ frontend/src/hooks/use-toast.ts | 186 +++++ frontend/src/index.css | 84 +++ frontend/src/lib/mock-data.ts | 127 ++++ frontend/src/lib/utils.ts | 6 + frontend/src/main.tsx | 5 + frontend/src/pages/Index.tsx | 93 +++ frontend/src/pages/NotFound.tsx | 24 + frontend/src/pages/Preferences.tsx | 88 +++ frontend/src/test/example.test.ts | 7 + frontend/src/test/setup.ts | 15 + frontend/src/vite-env.d.ts | 1 + frontend/tailwind.config.ts | 60 ++ frontend/tsconfig.app.json | 35 + frontend/tsconfig.json | 24 + frontend/tsconfig.node.json | 22 + frontend/vite.config.ts | 21 + frontend/vitest.config.ts | 16 + 89 files changed, 5438 insertions(+) create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/components.json create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/playwright-fixture.ts create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/public/placeholder.svg create mode 100644 frontend/public/robots.txt create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/CategoryTabs.tsx create mode 100644 frontend/src/components/NavLink.tsx create mode 100644 frontend/src/components/ResultCard.tsx create mode 100644 frontend/src/components/ResultSkeleton.tsx create mode 100644 frontend/src/components/SearchInput.tsx create mode 100644 frontend/src/components/ui/accordion.tsx create mode 100644 frontend/src/components/ui/alert-dialog.tsx create mode 100644 frontend/src/components/ui/alert.tsx create mode 100644 frontend/src/components/ui/aspect-ratio.tsx create mode 100644 frontend/src/components/ui/avatar.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/breadcrumb.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/calendar.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/carousel.tsx create mode 100644 frontend/src/components/ui/chart.tsx create mode 100644 frontend/src/components/ui/checkbox.tsx create mode 100644 frontend/src/components/ui/collapsible.tsx create mode 100644 frontend/src/components/ui/command.tsx create mode 100644 frontend/src/components/ui/context-menu.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/drawer.tsx create mode 100644 frontend/src/components/ui/dropdown-menu.tsx create mode 100644 frontend/src/components/ui/form.tsx create mode 100644 frontend/src/components/ui/hover-card.tsx create mode 100644 frontend/src/components/ui/input-otp.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/menubar.tsx create mode 100644 frontend/src/components/ui/navigation-menu.tsx create mode 100644 frontend/src/components/ui/pagination.tsx create mode 100644 frontend/src/components/ui/popover.tsx create mode 100644 frontend/src/components/ui/progress.tsx create mode 100644 frontend/src/components/ui/radio-group.tsx create mode 100644 frontend/src/components/ui/resizable.tsx create mode 100644 frontend/src/components/ui/scroll-area.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/components/ui/separator.tsx create mode 100644 frontend/src/components/ui/sheet.tsx create mode 100644 frontend/src/components/ui/sidebar.tsx create mode 100644 frontend/src/components/ui/skeleton.tsx create mode 100644 frontend/src/components/ui/slider.tsx create mode 100644 frontend/src/components/ui/sonner.tsx create mode 100644 frontend/src/components/ui/switch.tsx create mode 100644 frontend/src/components/ui/table.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/components/ui/textarea.tsx create mode 100644 frontend/src/components/ui/toast.tsx create mode 100644 frontend/src/components/ui/toaster.tsx create mode 100644 frontend/src/components/ui/toggle-group.tsx create mode 100644 frontend/src/components/ui/toggle.tsx create mode 100644 frontend/src/components/ui/tooltip.tsx create mode 100644 frontend/src/components/ui/use-toast.ts create mode 100644 frontend/src/contexts/PreferencesContext.tsx create mode 100644 frontend/src/hooks/use-mobile.tsx create mode 100644 frontend/src/hooks/use-search.ts create mode 100644 frontend/src/hooks/use-toast.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/mock-data.ts create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Index.tsx create mode 100644 frontend/src/pages/NotFound.tsx create mode 100644 frontend/src/pages/Preferences.tsx create mode 100644 frontend/src/test/example.test.ts create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 frontend/vitest.config.ts diff --git a/.gitignore b/.gitignore index 19776c8..6cea500 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ node_modules/ .agent/ internal/spa/dist/ +frontend/node_modules/ +frontend/dist/ +frontend/bun.lock +frontend/bun.lockb +frontend/package-lock.json *.exe *.exe~ *.dll diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..a125fd6 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,3 @@ +# Welcome to your Lovable project + +TODO: Document your project here diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..62e1011 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..40f72cc --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,26 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + "@typescript-eslint/no-unused-vars": "off", + }, + }, +); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c1ff5ee --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,29 @@ + + + + + + + kafka — Private Meta-Search + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e90cada --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,90 @@ +{ + "name": "vite_react_shadcn_ts", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "build:dev": "vite build --mode development", + "lint": "eslint .", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@hookform/resolvers": "^3.10.0", + "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-aspect-ratio": "^1.1.7", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-collapsible": "^1.1.11", + "@radix-ui/react-context-menu": "^2.2.15", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-hover-card": "^1.1.14", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-menubar": "^1.1.15", + "@radix-ui/react-navigation-menu": "^1.2.13", + "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.7", + "@radix-ui/react-scroll-area": "^1.2.9", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slider": "^1.3.5", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-toast": "^1.2.14", + "@radix-ui/react-toggle": "^1.1.9", + "@radix-ui/react-toggle-group": "^1.1.10", + "@radix-ui/react-tooltip": "^1.2.7", + "@tanstack/react-query": "^5.83.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", + "lucide-react": "^0.462.0", + "next-themes": "^0.3.0", + "react": "^18.3.1", + "react-day-picker": "^8.10.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.61.1", + "react-resizable-panels": "^2.1.9", + "react-router-dom": "^6.30.1", + "recharts": "^2.15.4", + "sonner": "^1.7.4", + "tailwind-merge": "^2.6.0", + "tailwindcss-animate": "^1.0.7", + "vaul": "^0.9.9", + "zod": "^3.25.76" + }, + "devDependencies": { + "@eslint/js": "^9.32.0", + "@playwright/test": "^1.57.0", + "@tailwindcss/typography": "^0.5.16", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.0.0", + "@types/node": "^22.16.5", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react-swc": "^3.11.0", + "autoprefixer": "^10.4.21", + "eslint": "^9.32.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^15.15.0", + "jsdom": "^20.0.3", + "lovable-tagger": "^1.1.13", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", + "typescript": "^5.8.3", + "typescript-eslint": "^8.38.0", + "vite": "^5.4.19", + "vitest": "^3.2.4" + } +} diff --git a/frontend/playwright-fixture.ts b/frontend/playwright-fixture.ts new file mode 100644 index 0000000..7d471c1 --- /dev/null +++ b/frontend/playwright-fixture.ts @@ -0,0 +1,3 @@ +// Re-export the base fixture from the package +// Override or extend test/expect here if needed +export { test, expect } from "lovable-agent-playwright-config/fixture"; diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..ec19e95 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,10 @@ +import { createLovableConfig } from "lovable-agent-playwright-config/config"; + +export default createLovableConfig({ + // Add your custom playwright configuration overrides here + // Example: + // timeout: 60000, + // use: { + // baseURL: 'http://localhost:3000', + // }, +}); diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..3c01d69713f9c184e92b74f5799e6dff2f500825 GIT binary patch literal 20373 zcmZQzU}Ruq00Bk@1%`Tm1_m((28PZ6KX+a(DJ}*E23}7OmmrWT5awWGU|@(TT9L-U z;P2+?;uunK>+Rm^E4i;@#lG3Rzh$|zz}ujQ!O~+<%j)Lttzs*igC;nfY;5u7aCQyk zvY2vYN?^hkliUUlw~5hCXBk|Tn4DansBxrDF^qEV+OjA)Ab63G0;6d}N9U6#Z}Ru< z|MzkCzV~xp->#m&`TM_bcCTwEyy--)7Nx-|~Pr<4?8A|Gb9% z2lwxDYHd9d#IRlRy8N#Ip})-s|EV_oDGZpm@K99Cyq_mF{BULDk#i{h`&Z_6U-7A* zpY2-gD%cz3nLp%C$hJRz{m-i)7H)6GJAN_s_uBv11?}US;LrZFHt5B1d!Y%AI|^?x zD>!gmC~MiL)wD~#s%OI6{RSWXCfKt+m}(LC+y3V)^RKJ_-4$EM=goMB&F0eu`3knD zKc+7DleuXB_8AW*M?Yj|Wl`YOTrlP0tJ`Mfdm3ZQ>yI-h)Y|30{@l*^N5o~H%!B<; z{{NY_l;fO$H=_+(jLnyc{z5PHnVw`f{$td-BP%rR-xPL*35|aa_pvKnF08)%tmXdG zM27UG0fz6Ytvm7=KIGPXjsEvH^#3yMa&d1)8?Tu9d+zq!FY0+G__O!<2P}M(`Sooj zmtE3-hhMF&)92opZy_niuzc-*&C~l=^Kdw@9I$8oA^zi)`Tl>a>TkF1ZePlfCn(PU z_hIviqs|llWo@wMC}B{0@gjQr{eDr$XA49cf!G>s)o-R-$j8*bu&?gwsNp^Ep7~>f%#7Hd2jW)wDQsLE_duj8*&Xu2R`OA|Fv(I;gnH-_R2#rUb6Vi2_!V$p!cQ zs~)t5v+dlWb%9d2k%dJ&;Qw3{`7dNmu`xhsw@GSfMn+K}T^FH!0+}}Lqz++)= z#yJPRKDYmV(Cv>tgT2%DHv(0Yzg~Yk_ms`~-_K9p;QGeHFYi-fIbZwm?H=jN#S?|! zH_k4R{jiT=UwGV~GyS_K@Bew|?(rZ7>xw6=^S{M0{8ww}_c}OJN3UqR)RUb&`BuIv zr|SKu{+YM!gmvEkrA^hs9}nK!Cv0`-0}sP}ef_!*&3U)^_ZB>j;7T}T9si*zTjd{D z$Ma);pV*wXES6CdkFS_{WSYQ1&Yd^(Z+D;7zilBp-TRa0)57HZ2aXQ=qT~KNxGaA* ze|N$AM9qdK_qZQ7-$uQz7knVjcjecUU+(4e-mmFO;P~{_=C073YmAc*2wCU--xu)t z$z<+hR+-L8tee)Er*j+&FXyKfzunyATe_^^J}#mOFZ(g(i#yxy%`!#aD< z?+2P2G~V;`h2a=k9jTn{Z_EdwbQ_PdA;Oqo9-Tz(8Z(yI+@exQBf!x{I&r>)ft>FXJ8@XhD_B>W~)uuMgG|4Ei9 ze}9MDZmfPLl6S0N+Vwe)l*=wp*;Kvq_J8J2wGMw&D_?G&CSD-_XpQIr<@x_z-}8OS z-oDai>xsyn)9b&KF@)qVXyFU)*zft^?h1wtNy1!n4$PZ+kDE77pFQCI@68wV#S|+* zni-ych;RPW;CNyt^Uo(Cj1#!+76w;m%`E7>{XNe9`p^3(^3J(Bnm;v7c&(VD-I1?; zPgY5P>i!>%>9zKE`z>zQZ+IR5Vd;C;w*u78N zoAzBfVvTM7pGh~S{kcH%klr5rJ9Wwbjf6lr1>%GYH&34CDdz(H` zxpAQ2UEI$AnHxFr$24HNaeDY`7 zv25DLw-G<*mONOZu<7{zCC?7sUB{L9KKRZ8IR*ebz}ompklv9$UG*^>)+d z5Qc=`0UK(W%$xR2zO=bkrZ1U?b(I1CSF0eoylemMLci@6cDhsj!?vO3UTA$s>+Iu| zS`1sxGv1y&lhw;?byf2k`*SIChKe%0=Va!+Vp1J44x;>{4 zpt8pe>zu8&iI@k(#_*h*V_C$TLeHF}^ zZ~t19ZDM2aYvnYW{UA5(;j)d>x%NaZw6kUSl2*5OwvFe%)1i_T^$*sFH0=5El!3oK zM<79{X_=KG$38Qz-P)bKTj$%h?OOF*=Yi-_pMv!>;`7|f))>6l^W<6O*H!mTOO| zU7z;w+e7}7-gWQa?t6A9_t;L&hW;B{zpYJSdN!jjm?h*ji{DZf?tF#d;|V#p7eCq7 z^6}Y8Lk9JIH_EP`J@I+&BHk~1?OFE5?{q9ak-M zLr$jX|720|V6Hm_%+<$N1^C}{e#x-sW6*=eNeY=pu_q4QeSYG~A`iWP%A2IsV!HNT zzjx;6^!>+wH^nSI{JwBbFO$Ft;l^+GnoryRy(kxZBh{kLEiiH_dB&P2B6MfPtPg27|Id~U+4S?x;YfCed+)dXW!AeRIH#8> z8%zFcecb+9aYO3H3vVoWuFq(_yP3V=gvt@=>(<7sO((f`@I2iUYxLn_QT3+J<_+fi zwk51pDCvKkBf@ZVUE^C#y**8l7q|<}Iez|7m(x1q)NiRG@qObm9&@=y`#Gxw9k_PI zl{Q#KZji;$zgt~(<4LicxNh0k@$nWhDc!Seav%OXY}v~2?Ai8>pUyx2E5u~* zgYh|c*0Tg9j`nX4PqC+HC|tcOFV&=We&IW2KNFtB|2O_Ua(ukX`Jj5sG0VCC_qSy1 z5t*LGC-%;CPA|K{TZUa*)3lghX1zSP?n&I`4-UG27;~7uq{THvSAP|hf=Y&2!>sk6HLe zfyJLqKd!q+$L0fb;X4tb9E&FJ3if(I|C(vhjqOI|bv!>0JMYO8&t;hl!Ua92EL(&1RP%7PR}En`6Ri zmOqV)uC`4LIZ|=&+1Kjhg^y;`pR2ro$hPxyAcODSs!p5wjsMQ^E@<34h2iiX*7Cng z-|&myw{2X+V#82&-R98SxLI9uJl4n;eY=~vUU+(RKw7pf_uk__jpDY&6O~z9J>z-MueD_Hi^PZb4*b+$Ffl$-UCUsqqM=vc!l3rVw$DGaIMe@`z+gK4I9sRb(jxf5frF z+~3X4xid!ioD=vo@6h>4|MQpe?5bq_8*t~Bkl2E#|EHKM*w<7uPvtJCkYGsQyT@fbI8L_RnlR&S(*( z+fd=#u=^v^mUXSpYnyA&74K9!u;YGrG9XH_G z9^$(BH}l*ItLtha_Ywo_KL0$>#rUpv&P=8Rtp)|gv&mMFGmp#q6 z;QZqpk%kRNUSF9amo0JO%+c>R_Lq8?>oJJ^w&quOYN@lCp^@*v$`pmNO-!#kmo$D+ zxXtL;O< zEAP!D>JDDuF`1WnTr0EVr9J!y%0*R*0o&c?zgt(M$+_)E{G6Z1 z7gn(R>!{&*^MUbegy5}-v#qlvjAGtQ5n6Fg-t(X0_Nm-6E|t#aar_&&tbbRyO^MdM z_n%o$ar-S*6@O<5s+v;795lkjVziiIPk7(rpR)FAx!!@*{_C(vvdGX)>r^O-b zZ#z~>&bZX%9JM}9BeK@;;qu7WYvlGVaXT>oysO>xq*ci`yqWJ^k!t4-@7x^7kSyFc z?f0(?eXGy!BN=17AM(Gx-~03XkNvxTXo&67YVH)_*{h)XWL=Q$Rn`rk7?<&|ygb%k z{_x3y`tW|1HCYAxyA==k*=?vhCLQ%va}R^utN0I$=Ucujo^yG$#yCA+S$XyLzz^23 zKW48zz25o9d#j%uE_&>|M({=CT z(yi^=?LP3|t5|fjD3L#XF~b4oBh`OiHpQ>C_xUlGd&QlG$a?XbMlp_qU9Epg6`x!? z#x2=f%rW)Njyn=nxe})qDSP#rPi<1Q4$wE>BiUti;X=b_OC9+|Tp!#x&m8+Ea?|3a zsQ8l~3f4!RKUO#idaS-Sh zNmXx^M1$t>ht_#K-E>)c!tM~s7f+4lCKOFPp*7{c0iS>V_Svy_f|(Ax-x4gV?>W)+ zaLS4g)8)0!J!L$=-t~Z4v1T7b{{9^m559_@|NY>4)7#?(Z}Rtl6W+gP%0WI*woqN* zaG-Ysv$N0rt^<|d?@!>=kZe?ZD*oOqr%>rJW6#RJAJ}p-I=(pF*=-fhxL!6>K|PHF?s(ErGc*o?&SDqE7D3 z9%q^RwNFfW`J?WJ_uVvm%Wu)J!)zZv-!#?@jH($wYi1bl*v!^=<9Prhh>Q>m;Zqa0W2#8 zBEEf^A@fWiWwL)=!td8lcUCiA)!uMk@>1vARr`YMV%EoNvpp_t`t`K!ro!cy%-`lP zT5hd9^;MsX*{|!|`uR=`2c1`X@h!f@c;CN8pYezKy82V@`@0^P%l|m?|G@m3zl(Rz zms7Wp=0BkR?}w9o($DJ7iw|B3rZi88{*@N?FJYS9+FexwN0}eoyKl;|{$`T$JJB1a zXSgq%PdaX(=cZ(nB71mCNTiR}s{Yyb)pJE&+gGra`NquRG`b2nU>Vbk+) zYmlAtrQZ^Ei+zNrzpeQ`CFMikvy*3etpXO>%4Wphy%<-&H@;?C&8y?{z6w1{U(CSb z+v4kdq~QDN1KT}0cNENK;LocUeBda#qiDweOIOv`Gk)+qa(VvUBZ5^2{%?BVaq3$| zP7p`)gR@PWmDlWMvW$AtyL+Et-W!hJs@LyF>iK@wT5KfYJZ;v#UstbbCw{@1@fT5|$978L*6yC&}Bf#z=y861S?vM2Qi@+TbM<6^NV zTb=psrB`!jTT89K`|D`a;=RQP2H?%~sE{I@Vz|Y)kkg(yCZcLTtwb(y<3Vz=`lyA$J zIeGboKT;_uA(x@LS`@1=LEk>#$pbGXg5to$B5f6|C~I<_uEgfXL>6`Gpm1i zeT+q_{+5R)->Xfpix2y5f30ZBt-B}PK7BZN=-cJ%t?^ZVch0W}do4Mymt7%e*84Q! zg%NCbqRM;!e3D|+Kcbirwtoqi_k6xp?hF#;YA3HAwOi61(-XO8oAchuXZ}iRXB-OL z%c#tF^qxXni_?O4XH@SU2(UPEM}FGYW&c*CH+{OIsfC%>vdn;@0C4& ze`fX3pBfBi-p6Myy}tK@h?Ms%AC1(z{(Yf46#vM)Dw^Z%VaFiRVt2~_*bb8^2aYhm zNM?UJ!|Omy$}fhA0zZ<@UVJ;N+3irmM@F8>or@=~v~%BSwjq#ZoyxO^%85JOSI5_O zoqk-q#9+EaT-$fQhtK5L18i3GK9%3oB({HLyS$UorFqqFel(WbzBk_gpws@J^Zv^4 zybIcgerho62sLsIxLbF3Zsor+38_zemV_T_ZV+rW+V$(=A^+8?CsGe?opV3$m|uf< z;pF@&3|&GRw_Tb&%$cKY8V-I6PM2kpQsc-id+w7doex=|xVayOfK|3mJ{G_Po>fS8V+C!pn(u9>bECXFV6B2)$`D?e_mJ z^hfQD+MBgTH{~w>_RY=NU&*jg;KIbP+m;NXR~|4p39c}^B;a)D^UWeAiS4VOy4N20 zVN>G%)=0+x@w3#P?I-?z)yezjy_QGj+{pruJG*}GG>lz&hxv8?m0DszI{Rw{68 z^B39jzW(}W`~R)}e;&TCi)mv9l|Z~nR&lI<19}#^{CH9H^!twRw@0n!g=jxIKXt)3 zgN0msj?NQf+R^SC%u;5!;&y++Y13=#uhy~LxcYXw@`vtCF3>C0?^0N6@wjps z>rxHIb6p9}Kd#2yeIhtV>c_#Nm5I+) vUHCut(T;b?DFT1wSIEp?v(Ll${mLui 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..6018e70 --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,14 @@ +User-agent: Googlebot +Allow: / + +User-agent: Bingbot +Allow: / + +User-agent: Twitterbot +Allow: / + +User-agent: facebookexternalhit +Allow: / + +User-agent: * +Allow: / diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..f1ed102 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,31 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { Toaster as Sonner } from "@/components/ui/sonner"; +import { Toaster } from "@/components/ui/toaster"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { PreferencesProvider } from "@/contexts/PreferencesContext"; +import Index from "./pages/Index.tsx"; +import Preferences from "./pages/Preferences.tsx"; +import NotFound from "./pages/NotFound.tsx"; + +const queryClient = new QueryClient(); + +const App = () => ( + + + + + + + + } /> + } /> + } /> + + + + + +); + +export default App; diff --git a/frontend/src/components/CategoryTabs.tsx b/frontend/src/components/CategoryTabs.tsx new file mode 100644 index 0000000..1be1eda --- /dev/null +++ b/frontend/src/components/CategoryTabs.tsx @@ -0,0 +1,39 @@ +import { Globe, Code, Image, Newspaper } from "lucide-react"; +import { CATEGORIES, type Category } from "@/lib/mock-data"; + +const CATEGORY_META: Record = { + general: { label: "General", icon: Globe }, + it: { label: "IT", icon: Code }, + images: { label: "Images", icon: Image }, + news: { label: "News", icon: Newspaper }, +}; + +interface CategoryTabsProps { + active: Category; + onChange: (c: Category) => void; +} + +export function CategoryTabs({ active, onChange }: CategoryTabsProps) { + return ( +
+ {CATEGORIES.map((cat) => { + const { label, icon: Icon } = CATEGORY_META[cat]; + const isActive = cat === active; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/NavLink.tsx b/frontend/src/components/NavLink.tsx new file mode 100644 index 0000000..a561a95 --- /dev/null +++ b/frontend/src/components/NavLink.tsx @@ -0,0 +1,28 @@ +import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom"; +import { forwardRef } from "react"; +import { cn } from "@/lib/utils"; + +interface NavLinkCompatProps extends Omit { + className?: string; + activeClassName?: string; + pendingClassName?: string; +} + +const NavLink = forwardRef( + ({ className, activeClassName, pendingClassName, to, ...props }, ref) => { + return ( + + cn(className, isActive && activeClassName, isPending && pendingClassName) + } + {...props} + /> + ); + }, +); + +NavLink.displayName = "NavLink"; + +export { NavLink }; diff --git a/frontend/src/components/ResultCard.tsx b/frontend/src/components/ResultCard.tsx new file mode 100644 index 0000000..62209eb --- /dev/null +++ b/frontend/src/components/ResultCard.tsx @@ -0,0 +1,40 @@ +import type { SearchResult } from "@/lib/mock-data"; + +interface ResultCardProps { + result: SearchResult; +} + +export function ResultCard({ result }: ResultCardProps) { + const domain = result.parsed_url[1]; + const faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; + + return ( +
+
+ + {result.pretty_url} + {result.engines.length > 1 && ( + + {result.engines.length} engines + + )} +
+

+ {result.title} +

+

+ {result.content} +

+ {result.publishedDate && ( + + {new Date(result.publishedDate).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })} + + )} +
+ ); +} diff --git a/frontend/src/components/ResultSkeleton.tsx b/frontend/src/components/ResultSkeleton.tsx new file mode 100644 index 0000000..0119f4c --- /dev/null +++ b/frontend/src/components/ResultSkeleton.tsx @@ -0,0 +1,19 @@ +export function ResultSkeleton() { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/SearchInput.tsx b/frontend/src/components/SearchInput.tsx new file mode 100644 index 0000000..9d1cfe5 --- /dev/null +++ b/frontend/src/components/SearchInput.tsx @@ -0,0 +1,43 @@ +import { Search } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { FormEvent, useRef, useEffect } from "react"; + +interface SearchInputProps { + query: string; + onQueryChange: (q: string) => void; + onSearch: (q: string) => void; + compact?: boolean; + autoFocus?: boolean; +} + +export function SearchInput({ query, onQueryChange, onSearch, compact, autoFocus }: SearchInputProps) { + const inputRef = useRef(null); + + useEffect(() => { + if (autoFocus) inputRef.current?.focus(); + }, [autoFocus]); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + onSearch(query); + }; + + return ( +
+
+ + onQueryChange(e.target.value)} + placeholder="Search the web privately..." + className={`pl-10 pr-4 border-input bg-background focus-visible:ring-ring ${compact ? "h-9 text-sm" : "h-12 text-base"}`} + /> +
+ +
+ ); +} diff --git a/frontend/src/components/ui/accordion.tsx b/frontend/src/components/ui/accordion.tsx new file mode 100644 index 0000000..1e7878c --- /dev/null +++ b/frontend/src/components/ui/accordion.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className, + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..6dfbfb4 --- /dev/null +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,104 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx new file mode 100644 index 0000000..2efc3c8 --- /dev/null +++ b/frontend/src/components/ui/alert.tsx @@ -0,0 +1,43 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/frontend/src/components/ui/aspect-ratio.tsx b/frontend/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..c9e6f4b --- /dev/null +++ b/frontend/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"; + +const AspectRatio = AspectRatioPrimitive.Root; + +export { AspectRatio }; diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx new file mode 100644 index 0000000..68d21bb --- /dev/null +++ b/frontend/src/components/ui/avatar.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..0853c44 --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps extends React.HTMLAttributes, VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/frontend/src/components/ui/breadcrumb.tsx b/frontend/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..ca91ff5 --- /dev/null +++ b/frontend/src/components/ui/breadcrumb.tsx @@ -0,0 +1,90 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>