T13: Dodat search bar sa instant pretragom taskova i dokumenata
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
563abd8481
commit
a3fc9b3af0
70
TASKS/reports/T13-report.md
Normal file
70
TASKS/reports/T13-report.md
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# T13 Izveštaj: Dashboard — pretraga taskova i dokumentacije
|
||||||
|
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Opus
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Šta je urađeno
|
||||||
|
|
||||||
|
Dodat search bar na dashboard sa instant pretragom (HTMX debounce 300ms).
|
||||||
|
|
||||||
|
### Novi fajlovi
|
||||||
|
|
||||||
|
| Fajl | Opis |
|
||||||
|
|------|------|
|
||||||
|
| `internal/server/search.go` | Search handler, matchTask, extractSnippet, containsInsensitive |
|
||||||
|
| `web/templates/partials/search-results.html` | Template za dropdown rezultate |
|
||||||
|
|
||||||
|
### Izmenjeni fajlovi
|
||||||
|
|
||||||
|
| Fajl | Izmena |
|
||||||
|
|------|--------|
|
||||||
|
| `internal/server/server.go` | GET /search ruta |
|
||||||
|
| `internal/server/render.go` | renderSearchResults(), search-results template u init() |
|
||||||
|
| `internal/server/server_test.go` | 8 novih testova, checker agent i report u test setup |
|
||||||
|
| `web/templates/layout.html` | Search input sa hx-get, click-outside zatvaranje |
|
||||||
|
| `web/static/style.css` | Search wrapper, dropdown, result items, status badges, snippet |
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
| Ruta | Opis |
|
||||||
|
|------|------|
|
||||||
|
| `GET /search?q={query}` | HTML fragment sa rezultatima pretrage |
|
||||||
|
|
||||||
|
### Šta pretražuje
|
||||||
|
|
||||||
|
1. **Taskovi** — ID, naslov, opis, agent, status
|
||||||
|
2. **Dokumenti** — svi .md fajlovi (sadržaj)
|
||||||
|
3. **Izveštaji** — reports/*.md (sadržaj)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Case insensitive pretraga
|
||||||
|
- Snippet sa kontekstom oko pogotka
|
||||||
|
- Status badge za taskove (done/active/review...)
|
||||||
|
- Klik na task → otvori detail panel
|
||||||
|
- Klik na dokument → navigacija na /docs/
|
||||||
|
- Max 20 rezultata
|
||||||
|
- Prazan query → prazan odgovor
|
||||||
|
- Nema rezultata → "Nema rezultata" poruka
|
||||||
|
- Click outside → zatvori dropdown
|
||||||
|
|
||||||
|
### Novi testovi — 8 PASS
|
||||||
|
|
||||||
|
```
|
||||||
|
TestSearch_FindsTask PASS
|
||||||
|
TestSearch_FindsTaskByID PASS
|
||||||
|
TestSearch_FindsDocument PASS
|
||||||
|
TestSearch_FindsReport PASS
|
||||||
|
TestSearch_EmptyQuery PASS
|
||||||
|
TestSearch_NoResults PASS
|
||||||
|
TestSearch_CaseInsensitive PASS
|
||||||
|
TestSearch_HasSnippet PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ukupno projekat: 109 testova, svi prolaze
|
||||||
|
|
||||||
|
- `go vet ./...` — čist
|
||||||
|
- `go build ./...` — prolazi
|
||||||
87
TASKS/review/T13.md
Normal file
87
TASKS/review/T13.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# T13: Dashboard — pretraga taskova i dokumentacije
|
||||||
|
|
||||||
|
**Kreirao:** planer
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Sonnet
|
||||||
|
**Zavisi od:** T12
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opis
|
||||||
|
|
||||||
|
Search bar na dashboardu. Pretražuje taskove i .md fajlove.
|
||||||
|
Rezultati se prikazuju instant dok korisnik kuca (debounce 300ms).
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /search?q={query} → rezultati pretrage (HTML fragment)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Šta pretražuje
|
||||||
|
|
||||||
|
1. **Taskovi** — ID, naslov, opis, agent, status (folder)
|
||||||
|
2. **Dokumenti** — sadržaj svih .md fajlova
|
||||||
|
3. **Izveštaji** — sadržaj reports/*.md
|
||||||
|
|
||||||
|
## Kako radi
|
||||||
|
|
||||||
|
```html
|
||||||
|
<input type="search"
|
||||||
|
hx-get="/search"
|
||||||
|
hx-trigger="keyup changed delay:300ms"
|
||||||
|
hx-target="#search-results"
|
||||||
|
name="q"
|
||||||
|
placeholder="Pretraži taskove i dokumente...">
|
||||||
|
|
||||||
|
<div id="search-results"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Format rezultata
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ 🔍 "checker" │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ 📋 T04: Checker — verifikacija [done] │
|
||||||
|
│ ...go build, go vet, go test... │
|
||||||
|
│ │
|
||||||
|
│ 📄 agents/checker/CLAUDE.md │
|
||||||
|
│ ...Build + Test verifikacija... │
|
||||||
|
│ │
|
||||||
|
│ 📊 T04-report.md │
|
||||||
|
│ ...10 testova, svi prolaze... │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Svaki rezultat:
|
||||||
|
- Ikona po tipu (📋 task, 📄 dokument, 📊 izveštaj)
|
||||||
|
- Naslov/putanja — klikabilan
|
||||||
|
- Snippet sa highlighted match (kontekst oko pogotka)
|
||||||
|
- Klik → otvori task detalj ili dokument
|
||||||
|
|
||||||
|
## Pretraga logika
|
||||||
|
|
||||||
|
- Case insensitive
|
||||||
|
- Pretraži: naslov, sadržaj, ID
|
||||||
|
- Sortiraj: taskovi prvo, pa dokumenti, pa izveštaji
|
||||||
|
- Max 20 rezultata
|
||||||
|
- Prazan query → sakrij rezultate
|
||||||
|
|
||||||
|
## Testovi
|
||||||
|
|
||||||
|
- GET /search?q=checker → vrati T04 i checker CLAUDE.md
|
||||||
|
- GET /search?q=T01 → vrati T01 task i T01-report
|
||||||
|
- GET /search?q=deploy → vrati deployer agent CLAUDE.md
|
||||||
|
- GET /search?q= → prazan odgovor
|
||||||
|
- GET /search?q=xyznepostoji → "Nema rezultata"
|
||||||
|
- Snippet sadrži kontekst oko pogotka
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pitanja
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Odgovori
|
||||||
@ -63,6 +63,7 @@ func init() {
|
|||||||
"templates/partials/column.html",
|
"templates/partials/column.html",
|
||||||
"templates/partials/task-card.html",
|
"templates/partials/task-card.html",
|
||||||
"templates/partials/task-detail.html",
|
"templates/partials/task-detail.html",
|
||||||
|
"templates/partials/search-results.html",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -106,6 +107,15 @@ func renderDocsView(data docsViewData) string {
|
|||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderSearchResults generates the search results HTML fragment.
|
||||||
|
func renderSearchResults(data searchResultsData) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := templates.ExecuteTemplate(&buf, "search-results", data); err != nil {
|
||||||
|
return "Greška pri renderovanju: " + err.Error()
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
// renderTaskDetail generates HTML fragment for task detail panel.
|
// renderTaskDetail generates HTML fragment for task detail panel.
|
||||||
func renderTaskDetail(t supervisor.Task, content string, hasReport bool) string {
|
func renderTaskDetail(t supervisor.Task, content string, hasReport bool) string {
|
||||||
data := taskDetailData{
|
data := taskDetailData{
|
||||||
|
|||||||
191
code/internal/server/search.go
Normal file
191
code/internal/server/search.go
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/dal/kaos/internal/supervisor"
|
||||||
|
)
|
||||||
|
|
||||||
|
// searchResult represents a single search result.
|
||||||
|
type searchResult struct {
|
||||||
|
Type string // "task", "doc", "report"
|
||||||
|
Icon string // emoji icon
|
||||||
|
Title string // display title
|
||||||
|
Link string // URL to navigate to
|
||||||
|
Snippet string // context around the match
|
||||||
|
Status string // task status (only for tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchResultsData holds data for the search results template.
|
||||||
|
type searchResultsData struct {
|
||||||
|
Query string
|
||||||
|
Results []searchResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSearch serves search results as an HTML fragment.
|
||||||
|
func (s *Server) handleSearch(c *gin.Context) {
|
||||||
|
query := strings.TrimSpace(c.Query("q"))
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
c.String(http.StatusOK, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
root := s.projectRoot()
|
||||||
|
var results []searchResult
|
||||||
|
|
||||||
|
// 1. Search tasks
|
||||||
|
tasks, _ := supervisor.ScanTasks(s.Config.TasksDir)
|
||||||
|
for _, t := range tasks {
|
||||||
|
if matchTask(t, query) {
|
||||||
|
snippet := taskSnippet(t, query)
|
||||||
|
results = append(results, searchResult{
|
||||||
|
Type: "task",
|
||||||
|
Icon: "📋",
|
||||||
|
Title: t.ID + ": " + t.Title,
|
||||||
|
Link: "/task/" + t.ID,
|
||||||
|
Snippet: snippet,
|
||||||
|
Status: t.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Search documents (non-report .md files)
|
||||||
|
docs := scanMarkdownFiles(root)
|
||||||
|
for _, doc := range docs {
|
||||||
|
// Skip task files and reports (handled separately)
|
||||||
|
if strings.HasPrefix(doc.Path, "TASKS/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(filepath.Join(root, doc.Path))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if containsInsensitive(string(content), query) || containsInsensitive(doc.Path, query) {
|
||||||
|
snippet := extractSnippet(string(content), query)
|
||||||
|
results = append(results, searchResult{
|
||||||
|
Type: "doc",
|
||||||
|
Icon: "📄",
|
||||||
|
Title: doc.Path,
|
||||||
|
Link: "/docs/" + doc.Path,
|
||||||
|
Snippet: snippet,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Search reports
|
||||||
|
reportsDir := filepath.Join(s.Config.TasksDir, "reports")
|
||||||
|
entries, _ := os.ReadDir(reportsDir)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(filepath.Join(reportsDir, entry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if containsInsensitive(string(content), query) || containsInsensitive(entry.Name(), query) {
|
||||||
|
// Extract task ID from report filename (T01-report.md → T01)
|
||||||
|
taskID := strings.TrimSuffix(entry.Name(), "-report.md")
|
||||||
|
snippet := extractSnippet(string(content), query)
|
||||||
|
results = append(results, searchResult{
|
||||||
|
Type: "report",
|
||||||
|
Icon: "📊",
|
||||||
|
Title: entry.Name(),
|
||||||
|
Link: "/report/" + taskID,
|
||||||
|
Snippet: snippet,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit results
|
||||||
|
if len(results) > 20 {
|
||||||
|
results = results[:20]
|
||||||
|
}
|
||||||
|
|
||||||
|
data := searchResultsData{
|
||||||
|
Query: query,
|
||||||
|
Results: results,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.String(http.StatusOK, renderSearchResults(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchTask checks if a task matches the query (case insensitive).
|
||||||
|
func matchTask(t supervisor.Task, query string) bool {
|
||||||
|
q := strings.ToLower(query)
|
||||||
|
return containsInsensitive(t.ID, query) ||
|
||||||
|
containsInsensitive(t.Title, query) ||
|
||||||
|
containsInsensitive(t.Description, query) ||
|
||||||
|
containsInsensitive(t.Agent, query) ||
|
||||||
|
strings.ToLower(t.Status) == q
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskSnippet returns a snippet from the task for display.
|
||||||
|
func taskSnippet(t supervisor.Task, query string) string {
|
||||||
|
// Try to find match in description first
|
||||||
|
if t.Description != "" && containsInsensitive(t.Description, query) {
|
||||||
|
return extractSnippet(t.Description, query)
|
||||||
|
}
|
||||||
|
// Fall back to description start
|
||||||
|
if t.Description != "" {
|
||||||
|
desc := t.Description
|
||||||
|
if len(desc) > 100 {
|
||||||
|
desc = desc[:100] + "..."
|
||||||
|
}
|
||||||
|
return desc
|
||||||
|
}
|
||||||
|
return t.Agent + " · " + t.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsInsensitive checks if s contains substr (case insensitive).
|
||||||
|
func containsInsensitive(s, substr string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractSnippet extracts a snippet around the first match with context.
|
||||||
|
func extractSnippet(content, query string) string {
|
||||||
|
lower := strings.ToLower(content)
|
||||||
|
q := strings.ToLower(query)
|
||||||
|
|
||||||
|
idx := strings.Index(lower, q)
|
||||||
|
if idx == -1 {
|
||||||
|
// Return first 100 chars
|
||||||
|
if len(content) > 100 {
|
||||||
|
return content[:100] + "..."
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get context: 40 chars before and 60 chars after
|
||||||
|
start := idx - 40
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
end := idx + len(query) + 60
|
||||||
|
if end > len(content) {
|
||||||
|
end = len(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
snippet := content[start:end]
|
||||||
|
|
||||||
|
// Clean up: remove newlines, trim
|
||||||
|
snippet = strings.ReplaceAll(snippet, "\n", " ")
|
||||||
|
snippet = strings.ReplaceAll(snippet, "\r", "")
|
||||||
|
snippet = strings.TrimSpace(snippet)
|
||||||
|
|
||||||
|
if start > 0 {
|
||||||
|
snippet = "..." + snippet
|
||||||
|
}
|
||||||
|
if end < len(content) {
|
||||||
|
snippet = snippet + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
return snippet
|
||||||
|
}
|
||||||
@ -97,6 +97,9 @@ func (s *Server) setupRoutes() {
|
|||||||
s.Router.POST("/task/:id/move", s.handleMoveTask)
|
s.Router.POST("/task/:id/move", s.handleMoveTask)
|
||||||
s.Router.GET("/report/:id", s.handleReport)
|
s.Router.GET("/report/:id", s.handleReport)
|
||||||
|
|
||||||
|
// Search route
|
||||||
|
s.Router.GET("/search", s.handleSearch)
|
||||||
|
|
||||||
// Docs routes
|
// Docs routes
|
||||||
s.Router.GET("/docs", s.handleDocsList)
|
s.Router.GET("/docs", s.handleDocsList)
|
||||||
s.Router.GET("/docs/*path", s.handleDocsView)
|
s.Router.GET("/docs/*path", s.handleDocsView)
|
||||||
|
|||||||
@ -58,6 +58,9 @@ func setupTestServer(t *testing.T) *Server {
|
|||||||
os.WriteFile(filepath.Join(dir, "README.md"), []byte("# README\n\nOpis projekta.\n"), 0644)
|
os.WriteFile(filepath.Join(dir, "README.md"), []byte("# README\n\nOpis projekta.\n"), 0644)
|
||||||
os.MkdirAll(filepath.Join(dir, "agents", "coder"), 0755)
|
os.MkdirAll(filepath.Join(dir, "agents", "coder"), 0755)
|
||||||
os.WriteFile(filepath.Join(dir, "agents", "coder", "CLAUDE.md"), []byte("# Coder Agent\n\nPravila kodiranja.\n"), 0644)
|
os.WriteFile(filepath.Join(dir, "agents", "coder", "CLAUDE.md"), []byte("# Coder Agent\n\nPravila kodiranja.\n"), 0644)
|
||||||
|
os.MkdirAll(filepath.Join(dir, "agents", "checker"), 0755)
|
||||||
|
os.WriteFile(filepath.Join(dir, "agents", "checker", "CLAUDE.md"), []byte("# Checker Agent\n\nBuild + Test verifikacija.\n"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(tasksDir, "reports", "T01-report.md"), []byte("# T01 Report\n\n10 testova, svi prolaze.\n"), 0644)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
TasksDir: tasksDir,
|
TasksDir: tasksDir,
|
||||||
@ -671,6 +674,116 @@ func TestDocsView_HasBreadcrumbs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearch_FindsTask(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/search?q=Prvi", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "T01") {
|
||||||
|
t.Error("expected T01 in search results for 'Prvi'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_FindsTaskByID(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/search?q=T08", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "T08") {
|
||||||
|
t.Error("expected T08 in search results")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_FindsDocument(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/search?q=checker", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "checker/CLAUDE.md") {
|
||||||
|
t.Error("expected checker CLAUDE.md in search results")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_FindsReport(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/search?q=prolaze", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "T01-report.md") {
|
||||||
|
t.Error("expected T01 report in search results")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_EmptyQuery(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/search?q=", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if w.Body.Len() != 0 {
|
||||||
|
t.Errorf("expected empty response for empty query, got %d bytes", w.Body.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_NoResults(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/search?q=xyznepostoji", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "Nema rezultata") {
|
||||||
|
t.Error("expected 'Nema rezultata' message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_CaseInsensitive(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/search?q=CODER", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "coder") {
|
||||||
|
t.Error("expected case-insensitive match for 'CODER'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_HasSnippet(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/search?q=kodiranja", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "kodiranja") {
|
||||||
|
t.Error("expected snippet with 'kodiranja' text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRewriteLinksSimple(t *testing.T) {
|
func TestRewriteLinksSimple(t *testing.T) {
|
||||||
input := `<a href="README.md">link</a> and <a href="https://example.com">ext</a>`
|
input := `<a href="README.md">link</a> and <a href="https://example.com">ext</a>`
|
||||||
result := rewriteLinksSimple(input, ".")
|
result := rewriteLinksSimple(input, ".")
|
||||||
|
|||||||
@ -226,6 +226,114 @@ body {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Header right section */
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search */
|
||||||
|
.search-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper input {
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #eee;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
width: 220px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper input:focus {
|
||||||
|
border-color: #e94560;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
width: 400px;
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #16213e;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||||
|
z-index: 50;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-dropdown:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #eee;
|
||||||
|
border-bottom: 1px solid #0f3460;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result:hover {
|
||||||
|
background: #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon { font-size: 0.9em; }
|
||||||
|
|
||||||
|
.search-title {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-status {
|
||||||
|
font-size: 0.7em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #0f3460;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-status-done { color: #4ecca3; }
|
||||||
|
.search-status-active { color: #e94560; }
|
||||||
|
.search-status-review { color: #ffd93d; }
|
||||||
|
.search-status-ready { color: #6ec6ff; }
|
||||||
|
.search-status-backlog { color: #888; }
|
||||||
|
|
||||||
|
.search-snippet {
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-empty {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Navigation */
|
/* Navigation */
|
||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -11,11 +11,23 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>🔧 KAOS Dashboard</h1>
|
<h1>🔧 KAOS Dashboard</h1>
|
||||||
|
<div class="header-right">
|
||||||
|
<div class="search-wrapper">
|
||||||
|
<input type="search" id="search-input"
|
||||||
|
hx-get="/search"
|
||||||
|
hx-trigger="keyup changed delay:300ms"
|
||||||
|
hx-target="#search-results"
|
||||||
|
name="q"
|
||||||
|
placeholder="Pretraži..."
|
||||||
|
autocomplete="off">
|
||||||
|
<div id="search-results" class="search-results-dropdown"></div>
|
||||||
|
</div>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="/" class="btn btn-active">Kanban</a>
|
<a href="/" class="btn btn-active">Kanban</a>
|
||||||
<a href="/docs" class="btn">Dokumenti</a>
|
<a href="/docs" class="btn">Dokumenti</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{{block "content" .}}{{end}}
|
{{block "content" .}}{{end}}
|
||||||
<div id="task-detail"></div>
|
<div id="task-detail"></div>
|
||||||
<div id="toast" class="toast"></div>
|
<div id="toast" class="toast"></div>
|
||||||
@ -43,6 +55,15 @@ function showToast(msg, type) {
|
|||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
initSortable();
|
initSortable();
|
||||||
|
|
||||||
|
// Close search results on click outside
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var wrapper = document.querySelector('.search-wrapper');
|
||||||
|
var results = document.getElementById('search-results');
|
||||||
|
if (wrapper && !wrapper.contains(e.target)) {
|
||||||
|
results.innerHTML = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.addEventListener('htmx:afterSwap', function(e) {
|
document.body.addEventListener('htmx:afterSwap', function(e) {
|
||||||
|
|||||||
16
code/web/templates/partials/search-results.html
Normal file
16
code/web/templates/partials/search-results.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{{define "search-results"}}
|
||||||
|
{{if .Results}}
|
||||||
|
{{range .Results}}
|
||||||
|
<a href="{{.Link}}" class="search-result" {{if eq .Type "task"}}hx-get="{{.Link}}" hx-target="#task-detail" hx-swap="innerHTML"{{end}}>
|
||||||
|
<div class="search-result-header">
|
||||||
|
<span class="search-icon">{{.Icon}}</span>
|
||||||
|
<span class="search-title">{{.Title}}</span>
|
||||||
|
{{if .Status}}<span class="search-status search-status-{{.Status}}">{{.Status}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="search-snippet">{{.Snippet}}</div>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<div class="search-empty">Nema rezultata za "{{.Query}}"</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
Loading…
Reference in New Issue
Block a user