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`