{{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 47/54] 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 48/54] 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 49/54] 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 50/54] 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 51/54] 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 52/54] 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 53/54] 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 54/54] 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 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 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) =>