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/task-card.html",
|
||||
"templates/partials/task-detail.html",
|
||||
"templates/partials/search-results.html",
|
||||
),
|
||||
)
|
||||
}
|
||||
@ -106,6 +107,15 @@ func renderDocsView(data docsViewData) 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.
|
||||
func renderTaskDetail(t supervisor.Task, content string, hasReport bool) string {
|
||||
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.GET("/report/:id", s.handleReport)
|
||||
|
||||
// Search route
|
||||
s.Router.GET("/search", s.handleSearch)
|
||||
|
||||
// Docs routes
|
||||
s.Router.GET("/docs", s.handleDocsList)
|
||||
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.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.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{
|
||||
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) {
|
||||
input := `<a href="README.md">link</a> and <a href="https://example.com">ext</a>`
|
||||
result := rewriteLinksSimple(input, ".")
|
||||
|
||||
@ -226,6 +226,114 @@ body {
|
||||
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 */
|
||||
.nav {
|
||||
display: flex;
|
||||
|
||||
@ -11,10 +11,22 @@
|
||||
<body>
|
||||
<div class="header">
|
||||
<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">
|
||||
<a href="/" class="btn btn-active">Kanban</a>
|
||||
<a href="/docs" class="btn">Dokumenti</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{{block "content" .}}{{end}}
|
||||
<div id="task-detail"></div>
|
||||
@ -43,6 +55,15 @@ function showToast(msg, type) {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
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) {
|
||||
|
||||
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