diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml index bd05693..5f7efb4 100644 --- a/.forgejo/workflows/test.yml +++ b/.forgejo/workflows/test.yml @@ -11,12 +11,15 @@ 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 with: go-version-file: go.mod + - name: Clean vendor + run: rm -rf vendor + - name: Test run: go test -race -v ./... diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..47cc920 --- /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@v5 + + - 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 ./... diff --git a/.gitignore b/.gitignore index a5388c7..6cea500 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +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/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) diff --git a/cmd/kafka/main.go b/cmd/kafka/main.go index cdc81b5..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,22 +79,20 @@ 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("/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 → 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, @@ -107,6 +104,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/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..28b98a1 --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md @@ -0,0 +1,1222 @@ +# 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 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 +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: Add FilterOption struct and update PageData struct** + +Add `FilterOption` struct at package level in `views.go` (near `PageNumber` struct): + +```go +// FilterOption represents a filter radio option for the sidebar. +type FilterOption struct { + Label string + Value string +} +``` + +Then update `PageData` struct to include new fields: + +```go +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 +} +``` + +- [ ] **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 (NumberOfResults, Results, etc.) ... + + // 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, + } + // ... rest of function ... +} +``` + +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** + +Update the sidebar category loop to conditionally apply `disabled` class: + +```html +{{range .Categories}} + + {{index $.CategoryIcons .}} + {{.}} + +{{end}} + +{{range .DisabledCategories}} + + {{index $.CategoryIcons .}} + {{.}} + +{{end}} +``` + +- [ ] **Step 6: Test compilation** + +Run: `go build ./...` +Expected: No errors + +- [ ] **Step 7: 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.

+ + + +
+ + 🌐 + All + + + 🖼️ + Images + + + 📰 + News + + + 🎬 + Videos + + + 🗺️ + Maps + + + 🛒 + Shopping + + + 🎵 + Music + + + 🌤️ + Weather + +
+
+
+{{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: 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 internal/views/views.go +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. +// 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) +} +``` + +- [ ] **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. 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` 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..d30ab99 --- /dev/null +++ b/docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md @@ -0,0 +1,328 @@ +# 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 (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 + +--- + +## 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`) +- **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 + +### Current State +- Two columns: compact search bar spanning top, main results + right sidebar + +### New Layout +``` +┌─────────────────────────────────────────────────────────────┐ +│ [Logo] [⚙ Preferences]│ +├─────────────────────────────────────────────────────────────┤ +│ ┌─────────┐ ┌────────────────────────────┐ ┌──────────┐│ +│ │ Nav │ │ 🔍 [ Search Input ] │ │ Related ││ +│ │ ─────── │ └────────────────────────────┘ │ Searches ││ +│ │ All │ About 1,240 results (0.42s) │ ││ +│ │ Images │ ┌──────────────────────────┐ │ │ ─────── ││ +│ │ Videos │ │ Result Card │ │ │ Suggestions│ +│ │ News │ │ Title, URL, Description │ │ │ ││ +│ │ Maps │ └──────────────────────────┘ │ └──────────┘│ +│ │ Shopping│ ┌──────────────────────────┐ │ │ +│ │ ... │ │ Result Card │ │ │ +│ │ │ │ ... │ │ │ +│ │ ─────── │ └──────────────────────────┘ │ │ +│ │ Filters │ ... │ │ +│ │ Time │ │ │ +│ │ Type │ [Pagination] │ │ +│ └─────────┘ │ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Implementation +- **Files:** `internal/views/templates/results.html`, `internal/views/templates/base.html` +- **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 (< 768px) + +- **Center Column:** + - Compact search bar + - Results count meta: "About {n} results ({time}s)" + - Result cards (unchanged markup) + - Pagination + +- **Right Sidebar:** + - Related searches (existing suggestions) + - Additional panels as needed + +### 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 + +--- + +## 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]│ +├─────────────────────────────────────────────────────────────┤ +│ ┌────────────────┐ ┌─────────────────────────────────────┐│ +│ │ Nav │ │ 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` +- **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 + +### 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 (`position: sticky; top: calc(var(--header-height) + 1rem)`) +- Category links with active state indicator +- Collapsible filter sections + +### Preferences Nav +- Vertical nav with section icons +- Active state indicator +- Mobile: horizontal scroll + +--- + +## 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; + 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 { + grid-template-columns: 1fr; + } + .results-layout .left-sidebar, + .results-layout .right-sidebar { + display: none; + } +} + +@media (min-width: 769px) and (max-width: 1024px) { + .results-layout { + grid-template-columns: 1fr 220px; + } + .results-layout .left-sidebar { + display: none; + } +} +``` + +--- + +## 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, 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 | + +--- + +## 7. Priority Order + +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) +5. **Phase 5:** Polish and mobile responsiveness + +--- + +## Out of Scope + +- Backend search logic changes +- New engine implementations (category tiles for future engines are UI placeholders only) +- Caching or performance improvements +- User authentication/account system +- Server-side preference storage 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 diff --git a/flake.nix b/flake.nix index d143495..3552728 100644 --- a/flake.nix +++ b/flake.nix @@ -21,13 +21,16 @@ version = "0.1.0"; src = ./.; - vendorHash = "sha256-NbAa4QM/TI3BTuZs4glx9k3ZjSl2/2LQfKlQ7izR8Ho="; + vendorHash = "sha256-8wlKD+33s97oorCJTfHKAgE2Xp1HKXV+bSr6z29KrKM="; # 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 before buildGoModule deletes it. + preConfigure = "rm -rf vendor || true"; + nativeCheckInputs = with pkgs; [ ]; # Tests require network; they run in CI instead. 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 0000000..3c01d69 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/placeholder.svg b/frontend/public/placeholder.svg new file mode 100644 index 0000000..ea950de --- /dev/null +++ b/frontend/public/placeholder.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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) =>