diff --git a/internal/features/anime/handler.go b/internal/features/anime/handler.go index 18b6eb5..50c9310 100644 --- a/internal/features/anime/handler.go +++ b/internal/features/anime/handler.go @@ -1,6 +1,7 @@ package anime import ( + "encoding/json" "log" "net/http" "strconv" @@ -124,3 +125,48 @@ func (h *Handler) HandleAPIAnimeRelations(w http.ResponseWriter, r *http.Request relations := h.svc.GetRelations(id) templates.AnimeRelationsList(relations).Render(r.Context(), w) } + +func (h *Handler) HandleQuickSearch(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + if query == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode([]interface{}{}) + return + } + + res, err := h.svc.Search(query, 1) + if err != nil { + log.Printf("quick search error: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + return + } + + // Limit to 10 results + results := res.Animes + if len(results) > 10 { + results = results[:10] + } + + type SearchResult struct { + ID int `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + Image string `json:"image"` + } + + output := make([]SearchResult, len(results)) + for i, anime := range results { + output[i] = SearchResult{ + ID: anime.MalID, + Title: anime.DisplayTitle(), + Type: anime.Type, + Image: anime.ImageURL(), + } + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(output) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 9718bff..2382118 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -36,6 +36,7 @@ func NewRouter(cfg Config) http.Handler { mux.HandleFunc("/", animeHandler.HandleCatalog) mux.HandleFunc("/search", animeHandler.HandleSearch) mux.HandleFunc("/api/search", animeHandler.HandleAPISearch) + mux.HandleFunc("/api/search-quick", animeHandler.HandleQuickSearch) mux.HandleFunc("/api/catalog", animeHandler.HandleAPICatalog) mux.HandleFunc("/anime/", animeHandler.HandleAnimeDetails) mux.HandleFunc("/api/anime/", animeHandler.HandleAPIAnimeRelations) diff --git a/internal/templates/layout.templ b/internal/templates/layout.templ index 7355e9f..e247768 100644 --- a/internal/templates/layout.templ +++ b/internal/templates/layout.templ @@ -20,15 +20,20 @@ templ Layout(title string) { watchlist - +
+ +
{ children... }
+ + } \ No newline at end of file diff --git a/internal/templates/layout_templ.go b/internal/templates/layout_templ.go index a33529f..f6cfca4 100644 --- a/internal/templates/layout_templ.go +++ b/internal/templates/layout_templ.go @@ -42,7 +42,7 @@ func Layout(title string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -50,7 +50,7 @@ func Layout(title string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/static/css/style.css b/static/css/style.css index e377cf8..8df9748 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -76,6 +76,11 @@ header { text-decoration: underline; } +.header-search-wrapper { + margin-left: auto; + position: relative; +} + .header-search { margin-left: auto; } @@ -99,6 +104,108 @@ header { color: var(--text-muted); } +/* Search Dropdown */ +.search-dropdown { + position: absolute; + top: 100%; + right: 0; + width: 320px; + background: var(--surface); + border: 1px solid var(--border); + border-top: none; + margin-top: -1px; + max-height: 480px; + overflow-y: auto; + z-index: 1000; +} + +.search-results { + display: flex; + flex-direction: column; +} + +.search-results-title { + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + border-bottom: 1px solid var(--border); +} + +.search-result-item { + display: flex; + gap: 10px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + text-decoration: none; + color: inherit; + transition: background 0.15s; +} + +.search-result-item:hover { + background: var(--surface-hover); +} + +.search-result-thumb { + width: 40px; + height: 60px; + object-fit: cover; + border: 1px solid var(--border); + flex-shrink: 0; +} + +.search-result-no-image { + width: 40px; + height: 60px; + display: flex; + align-items: center; + justify-content: center; + background: var(--surface-hover); + border: 1px solid var(--border); + font-size: 10px; + color: var(--text-muted); + flex-shrink: 0; +} + +.search-result-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.search-result-title { + font-size: 12px; + color: var(--text); + font-weight: 500; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.search-result-type { + font-size: 11px; + color: var(--text-muted); +} + +.search-result-view-all { + display: block; + padding: 10px 12px; + text-align: center; + color: var(--link); + font-size: 12px; + border-top: 1px solid var(--border); + text-decoration: none; +} + +.search-result-view-all:hover { + background: var(--surface-hover); + text-decoration: underline; +} + /* Main */ main { padding: 16px; diff --git a/static/js/search.js b/static/js/search.js new file mode 100644 index 0000000..61636bd --- /dev/null +++ b/static/js/search.js @@ -0,0 +1,62 @@ +let searchTimeout; +const searchInput = document.getElementById('search-input'); +const searchDropdown = document.getElementById('search-dropdown'); + +if (searchInput) { + searchInput.addEventListener('input', function(e) { + clearTimeout(searchTimeout); + const query = e.target.value.trim(); + + if (query.length < 2) { + searchDropdown.innerHTML = ''; + return; + } + + searchTimeout = setTimeout(() => { + fetch('/api/search-quick?q=' + encodeURIComponent(query)) + .then(res => res.json()) + .then(results => { + if (!results || results.length === 0) { + searchDropdown.innerHTML = ''; + return; + } + + let html = '
'; + html += '
Anime
'; + results.forEach(r => { + html += ` + + ${r.image ? `${r.title}` : '
no image
'} +
+
${escapeHtml(r.title)}
+
${r.type}
+
+
+ `; + }); + html += `View all results for ${escapeHtml(query)}`; + html += '
'; + searchDropdown.innerHTML = html; + }) + .catch(err => console.error('Search error:', err)); + }, 300); + }); + + searchInput.addEventListener('blur', () => { + setTimeout(() => { + searchDropdown.innerHTML = ''; + }, 200); + }); + + document.addEventListener('click', (e) => { + if (!e.target.closest('.header-search-wrapper')) { + searchDropdown.innerHTML = ''; + } + }); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +}