From 66edd9c062c8d13954c48b46097e305b9469a839 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sun, 17 May 2026 17:24:07 +0200 Subject: [PATCH] feat: replace quick search with command palette --- internal/anime/handler/handler.go | 198 ++++++++++++ static/search.ts | 426 +++++++++++++++---------- templates/components/navigation.gohtml | 23 +- 3 files changed, 472 insertions(+), 175 deletions(-) diff --git a/internal/anime/handler/handler.go b/internal/anime/handler/handler.go index 29212d9..d00c431 100644 --- a/internal/anime/handler/handler.go +++ b/internal/anime/handler/handler.go @@ -2,9 +2,12 @@ package handler import ( "fmt" + "mal/internal/db" "mal/internal/domain" "net/http" + "net/url" "strconv" + "strings" "github.com/gin-gonic/gin" ) @@ -36,6 +39,7 @@ func (h *AnimeHandler) Register(r *gin.Engine) { r.GET("/anime/:id/reviews", h.HandleAnimeReviews) r.GET("/api/watch-order", h.HandleHTMLWatchOrder) r.GET("/api/search-quick", h.HandleQuickSearch) + r.GET("/api/command-palette", h.HandleCommandPalette) r.GET("/api/jikan/random/anime", h.HandleRandomAnime) } @@ -385,6 +389,200 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) { c.JSON(http.StatusOK, output) } +type commandPaletteItem struct { + ID string `json:"id"` + Type string `json:"type"` + Label string `json:"label"` + Subtitle string `json:"subtitle"` + Href string `json:"href"` + Image string `json:"image,omitempty"` + Icon string `json:"icon,omitempty"` +} + +func (h *AnimeHandler) HandleCommandPalette(c *gin.Context) { + user, _ := c.Get("User") + u, ok := user.(*domain.User) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + query := strings.TrimSpace(c.Query("q")) + items := make([]commandPaletteItem, 0, 12) + + if query != "" { + items = append(items, commandPaletteItem{ + ID: "search:" + strings.ToLower(query), + Type: "search", + Label: fmt.Sprintf("Search anime for %q", query), + Subtitle: "Browse", + Href: "/browse?q=" + url.QueryEscape(query), + Icon: "search", + }) + + if len(query) >= 2 { + items = append(items, h.commandPaletteAnimeResults(c, query)...) + } + + items = append(items, h.commandPaletteNavigationItems(query)...) + items = append(items, h.commandPaletteContinueItems(c, u.ID, query)...) + items = append(items, h.commandPalettePersonalItems(c, u.ID, query)...) + c.JSON(http.StatusOK, items) + return + } + + items = append(items, h.commandPaletteContinueItems(c, u.ID, query)...) + items = append(items, h.commandPaletteNavigationItems(query)...) + items = append(items, h.commandPalettePersonalItems(c, u.ID, query)...) + c.JSON(http.StatusOK, items) +} + +func (h *AnimeHandler) commandPaletteNavigationItems(query string) []commandPaletteItem { + all := []commandPaletteItem{ + {ID: "nav:discover", Type: "navigation", Label: "Go to Discover", Subtitle: "Navigation", Href: "/discover", Icon: "compass"}, + {ID: "nav:watchlist", Type: "navigation", Label: "Go to Watchlist", Subtitle: "Navigation", Href: "/watchlist", Icon: "bookmark"}, + {ID: "nav:popular", Type: "navigation", Label: "Browse popular", Subtitle: "Browse", Href: "/browse?order_by=popularity&sort=desc", Icon: "trending"}, + {ID: "nav:airing", Type: "navigation", Label: "Currently airing", Subtitle: "Browse", Href: "/browse?status=airing&order_by=popularity&sort=desc", Icon: "play"}, + } + if query == "" { + return all + } + + filtered := make([]commandPaletteItem, 0, len(all)) + for _, item := range all { + if commandPaletteMatches(query, item.Label, item.Subtitle) { + filtered = append(filtered, item) + } + } + return filtered +} + +func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string) []commandPaletteItem { + res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, true, 1, 5) + if err != nil { + return nil + } + + items := make([]commandPaletteItem, 0, len(res.Animes)) + for _, anime := range res.Animes { + items = append(items, commandPaletteItem{ + ID: fmt.Sprintf("anime:%d", anime.MalID), + Type: "anime", + Label: anime.DisplayTitle(), + Subtitle: strings.TrimSpace("Anime " + anime.Type), + Href: fmt.Sprintf("/anime/%d", anime.MalID), + Image: anime.ImageURL(), + }) + } + return items +} + +func (h *AnimeHandler) commandPalettePersonalItems(c *gin.Context, userID string, query string) []commandPaletteItem { + items := make([]commandPaletteItem, 0, 5) + + watchlist, err := h.watchlistSvc.GetWatchlist(c.Request.Context(), userID) + if err != nil { + return items + } + + watchlistCount := 0 + for _, status := range []string{"watching", "plan_to_watch"} { + for _, entry := range watchlist { + if watchlistCount >= 5 { + return items + } + if entry.Status != status { + continue + } + + title := watchlistTitle(entry) + if query != "" && !commandPaletteMatches(query, title, entry.Status) { + continue + } + + items = append(items, commandPaletteItem{ + ID: fmt.Sprintf("watchlist:%d", entry.AnimeID), + Type: "watchlist", + Label: title, + Subtitle: watchlistStatusLabel(entry.Status), + Href: fmt.Sprintf("/anime/%d", entry.AnimeID), + Image: entry.ImageUrl, + }) + watchlistCount++ + } + } + + return items +} + +func (h *AnimeHandler) commandPaletteContinueItems(c *gin.Context, userID string, query string) []commandPaletteItem { + items := make([]commandPaletteItem, 0, 1) + + data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, "Continue") + if err == nil { + if rows, ok := data["ContinueWatching"].([]db.GetContinueWatchingEntriesRow); ok { + for _, row := range rows { + title := continueWatchingTitle(row) + if query != "" && !commandPaletteMatches(query, title, "Continue watching") { + continue + } + episode := "" + href := fmt.Sprintf("/anime/%d/watch", row.AnimeID) + if row.CurrentEpisode.Valid { + episode = fmt.Sprintf(" episode %d", row.CurrentEpisode.Int64) + href = fmt.Sprintf("%s?ep=%d", href, row.CurrentEpisode.Int64) + } + items = append(items, commandPaletteItem{ + ID: fmt.Sprintf("continue:%d", row.AnimeID), + Type: "continue", + Label: "Continue watching " + title, + Subtitle: "Resume" + episode, + Href: href, + Image: row.ImageUrl, + }) + break + } + } + } + + return items +} + +func commandPaletteMatches(query string, values ...string) bool { + needle := strings.ToLower(strings.TrimSpace(query)) + for _, value := range values { + if strings.Contains(strings.ToLower(value), needle) { + return true + } + } + return false +} + +func continueWatchingTitle(row db.GetContinueWatchingEntriesRow) string { + if row.TitleEnglish.Valid && row.TitleEnglish.String != "" { + return row.TitleEnglish.String + } + return row.TitleOriginal +} + +func watchlistTitle(row domain.UserWatchListRow) string { + if row.TitleEnglish.Valid && row.TitleEnglish.String != "" { + return row.TitleEnglish.String + } + return row.TitleOriginal +} + +func watchlistStatusLabel(status string) string { + switch status { + case "watching": + return "Watching" + case "plan_to_watch": + return "Plan to Watch" + default: + return "Watchlist" + } +} + func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) { anime, err := h.svc.GetRandomAnime(c.Request.Context()) if err != nil { diff --git a/static/search.ts b/static/search.ts index a43a04d..7c9a70f 100644 --- a/static/search.ts +++ b/static/search.ts @@ -1,22 +1,39 @@ -type QuickSearchResult = { - id?: number; +type CommandPaletteItem = { + id: string; + type: string; + label: string; + subtitle: string; + href: string; image?: string; - title?: string; - type?: string; + icon?: string; }; -// singleton flag to prevent double init (e.g. htmx swaps) -const searchInitializedKey = Symbol('searchInitialized'); -const globalWindow = window as Window & { [searchInitializedKey]?: boolean }; +const commandPaletteInitializedKey = Symbol('commandPaletteInitialized'); +const globalWindow = window as Window & { [commandPaletteInitializedKey]?: boolean }; -let searchTimeout: number | undefined; -const searchInput = document.getElementById('search-input') as HTMLInputElement | null; -const searchDropdown = document.querySelector( - '[data-search-results-container]' -) as HTMLElement | null; -const searchDialog = document.querySelector('[data-search-dialog]') as HTMLElement | null; -const searchOpenButtons = document.querySelectorAll('[data-search-open]'); -const searchCloseButtons = document.querySelectorAll('[data-search-close]'); +const paletteInput = document.getElementById('command-palette-input') as HTMLInputElement | null; +const paletteResults = document.querySelector('[data-command-palette-results]') as HTMLElement | null; +const paletteDialog = document.querySelector('[data-command-palette-dialog]') as HTMLElement | null; +const paletteRoot = document.querySelector('[data-command-palette-root]') as HTMLElement | null; +const paletteOpenButtons = document.querySelectorAll('[data-command-palette-open]'); +const paletteCloseButtons = document.querySelectorAll('[data-command-palette-close]'); +const shortcutHints = document.querySelectorAll('[data-command-palette-shortcut]'); + +let paletteItems: CommandPaletteItem[] = []; +let selectedIndex = 0; +let fetchTimeout: number | undefined; +let lastQuery = ''; +const responseCache = new Map(); + +const iconPaths: Record = { + bookmark: 'M19 21l-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z', + compass: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z M16.24 7.76l-2.12 6.36-6.36 2.12 2.12-6.36 6.36-2.12z', + play: 'M8 5v14l11-7z', + search: 'M11 19a8 8 0 1 1 5.65-2.35L21 21 M16.65 16.65 21 21', + trending: 'M3 17l6-6 4 4 8-8 M14 7h7v7', +}; + +const isMac = (): boolean => /Mac|iPhone|iPad|iPod/.test(navigator.platform); const isSafeImageUrl = (rawUrl?: string): boolean => { if (!rawUrl || typeof rawUrl !== 'string') { @@ -25,202 +42,283 @@ const isSafeImageUrl = (rawUrl?: string): boolean => { try { const parsed = new URL(rawUrl, window.location.origin); - // block data: URIs and other potentially dangerous protocols return parsed.protocol === 'https:' || parsed.protocol === 'http:'; } catch { return false; } }; -const clearSearchResults = (): void => { - if (!searchDropdown) { - return; - } +const isTypingTarget = (target: EventTarget | null): boolean => + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + (target instanceof HTMLElement && target.isContentEditable); - searchDropdown.replaceChildren(); -}; +const isOpen = (): boolean => paletteDialog?.classList.contains('flex') ?? false; -const openSearchDialog = (): void => { - if (!searchDialog || !searchInput) { - return; - } - - searchDialog.classList.remove('hidden'); - searchDialog.classList.add('flex'); - searchDialog.setAttribute('aria-hidden', 'false'); - searchInput.focus(); -}; - -const closeSearchDialog = (): void => { - if (!searchDialog || !searchInput) { - return; - } - - searchDialog.classList.add('hidden'); - searchDialog.classList.remove('flex'); - searchDialog.setAttribute('aria-hidden', 'true'); - searchInput.value = ''; - clearSearchResults(); -}; - -const buildSearchResultItem = (result: QuickSearchResult): HTMLAnchorElement => { - const item = document.createElement('a'); - item.className = - 'flex items-start gap-3 px-3 py-2 text-foreground no-underline hover:bg-surface-hover hover:no-underline'; - item.setAttribute('href', '/anime/' + encodeURIComponent(String(result.id || ''))); - - if (isSafeImageUrl(result.image)) { - const img = document.createElement('img'); - img.className = 'aspect-2/3 w-[42px] shrink-0 object-cover bg-background-surface'; - img.setAttribute('src', result.image || ''); - img.setAttribute('alt', String(result.title || '')); - item.appendChild(img); - } else { - const noImage = document.createElement('div'); - noImage.className = - 'aspect-2/3 w-[42px] shrink-0 bg-background-surface text-[0] text-transparent'; - noImage.textContent = 'no image'; - item.appendChild(noImage); - } - - const info = document.createElement('div'); - info.className = 'grid min-w-0 gap-px'; - - const itemTitle = document.createElement('div'); - itemTitle.className = 'line-clamp-1 text-[0.86rem] leading-[1.3] text-foreground'; - itemTitle.textContent = String(result.title || ''); - info.appendChild(itemTitle); - - const itemType = document.createElement('div'); - itemType.className = 'text-[0.67rem] text-foreground-muted'; - itemType.textContent = String(result.type || ''); - info.appendChild(itemType); - - item.appendChild(info); - return item; -}; - -const renderQuickSearchResults = (query: string, results: QuickSearchResult[]): void => { - if (!searchDropdown) { - return; - } - - if (!results || results.length === 0) { - clearSearchResults(); - return; - } - - const searchResults = document.createElement('div'); - searchResults.className = 'grid'; - - const title = document.createElement('div'); - title.className = 'px-3 py-2 text-[0.68rem] text-foreground-muted'; - title.textContent = 'Anime'; - searchResults.appendChild(title); - - results.forEach((result: QuickSearchResult) => { - searchResults.appendChild(buildSearchResultItem(result)); +const setShortcutHints = (): void => { + shortcutHints.forEach(hint => { + hint.textContent = isMac() ? '⌘P' : 'Ctrl P'; }); - - const viewAll = document.createElement('a'); - viewAll.className = - 'block bg-background-surface px-3 py-2 text-center text-[0.8rem] text-foreground-muted no-underline hover:bg-surface-hover hover:text-foreground hover:no-underline'; - viewAll.setAttribute('href', '/browse?q=' + encodeURIComponent(query)); - viewAll.textContent = 'Browse all results for ' + query; - searchResults.appendChild(viewAll); - - searchDropdown.replaceChildren(searchResults); }; -const fetchAndRenderQuickSearch = (query: string): void => { - fetch('/api/search-quick?q=' + encodeURIComponent(query)) - .then((res: Response) => res.json()) - .then((results: QuickSearchResult[]) => { - renderQuickSearchResults(query, results); +const clearResults = (): void => { + paletteResults?.replaceChildren(); +}; + +const buildIcon = (item: CommandPaletteItem): HTMLElement => { + if (isSafeImageUrl(item.image)) { + const img = document.createElement('img'); + img.className = 'h-11 w-8 shrink-0 bg-background-surface object-cover'; + img.src = item.image || ''; + img.alt = ''; + return img; + } + + const icon = document.createElement('div'); + icon.className = 'flex h-8 w-8 shrink-0 items-center justify-center text-foreground-muted'; + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', item.icon === 'play' ? 'currentColor' : 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '1.7'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + svg.classList.add('size-5'); + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', iconPaths[item.icon || 'search'] || iconPaths.search); + svg.appendChild(path); + icon.appendChild(svg); + + return icon; +}; + +const selectItem = (index: number): void => { + if (!paletteResults || paletteItems.length === 0) { + selectedIndex = 0; + return; + } + + selectedIndex = Math.max(0, Math.min(index, paletteItems.length - 1)); + paletteResults.querySelectorAll('[data-command-palette-item]').forEach((row, i) => { + const selected = i === selectedIndex; + row.classList.toggle('bg-surface-hover', selected); + row.setAttribute('aria-selected', String(selected)); + if (selected) { + row.scrollIntoView({ block: 'nearest' }); + } + }); +}; + +const runSelectedItem = (): void => { + const item = paletteItems[selectedIndex]; + if (!item) { + return; + } + window.location.href = item.href; +}; + +const buildRow = (item: CommandPaletteItem, index: number): HTMLAnchorElement => { + const row = document.createElement('a'); + row.href = item.href; + row.className = + 'flex min-h-12 items-center gap-3 px-4 py-2 text-foreground no-underline transition-colors hover:bg-surface-hover hover:no-underline'; + row.dataset.commandPaletteItem = item.id; + row.setAttribute('role', 'option'); + row.setAttribute('aria-selected', String(index === selectedIndex)); + + row.addEventListener('mouseenter', () => selectItem(index)); + + row.appendChild(buildIcon(item)); + + const copy = document.createElement('div'); + copy.className = 'grid min-w-0 flex-1 gap-0.5'; + + const label = document.createElement('div'); + label.className = 'truncate text-sm font-medium text-foreground'; + label.textContent = item.label; + copy.appendChild(label); + + if (item.subtitle && item.type !== 'navigation') { + const subtitle = document.createElement('div'); + subtitle.className = 'truncate text-xs text-foreground-muted'; + subtitle.textContent = item.subtitle; + copy.appendChild(subtitle); + } + + row.appendChild(copy); + + const hint = document.createElement('div'); + hint.className = 'hidden text-xs text-foreground-muted sm:block'; + hint.textContent = index === selectedIndex ? 'Enter' : ''; + row.appendChild(hint); + + return row; +}; + +const renderItems = (items: CommandPaletteItem[]): void => { + if (!paletteResults) { + return; + } + + paletteItems = items; + selectedIndex = 0; + + if (items.length === 0) { + const empty = document.createElement('div'); + empty.className = 'px-4 py-8 text-center text-sm text-foreground-muted'; + empty.textContent = 'No commands found'; + paletteResults.replaceChildren(empty); + return; + } + + const list = document.createElement('div'); + list.setAttribute('role', 'listbox'); + list.setAttribute('aria-label', 'Command palette results'); + items.forEach((item, index) => { + list.appendChild(buildRow(item, index)); + }); + paletteResults.replaceChildren(list); + selectItem(0); +}; + +const fetchPaletteItems = (query: string): void => { + lastQuery = query; + + const cached = responseCache.get(query); + if (cached) { + renderItems(cached); + return; + } + + fetch('/api/command-palette?q=' + encodeURIComponent(query)) + .then((res: Response) => { + if (!res.ok) { + return []; + } + return res.json(); + }) + .then((items: CommandPaletteItem[]) => { + if (query !== lastQuery) { + return; + } + responseCache.set(query, items); + renderItems(items); }) .catch((err: unknown) => { - console.error('Search error:', err); + console.error('Command palette error:', err); + renderItems([]); }); }; -const onSearchInput = (event: Event): void => { - if (searchTimeout) { - window.clearTimeout(searchTimeout); +const scheduleFetch = (): void => { + if (fetchTimeout) { + window.clearTimeout(fetchTimeout); } - const target = event.target; - if (!(target instanceof HTMLInputElement)) { - return; - } - - const query = target.value.trim(); - if (query.length < 2) { - clearSearchResults(); - return; - } - - searchTimeout = window.setTimeout(() => { - fetchAndRenderQuickSearch(query); - }, 300); + const query = paletteInput?.value.trim() || ''; + fetchTimeout = window.setTimeout(() => fetchPaletteItems(query), query.length >= 2 ? 180 : 0); }; -const onSearchBlur = (): void => { - // delay to allow clicking on results before clearing - window.setTimeout(() => { - clearSearchResults(); - }, 200); +const openPalette = (): void => { + if (!paletteDialog || !paletteInput) { + return; + } + + paletteDialog.classList.remove('hidden'); + paletteDialog.classList.add('flex'); + paletteDialog.setAttribute('aria-hidden', 'false'); + paletteInput.value = ''; + paletteInput.focus(); + fetchPaletteItems(''); +}; + +const closePalette = (): void => { + if (!paletteDialog || !paletteInput) { + return; + } + + paletteDialog.classList.add('hidden'); + paletteDialog.classList.remove('flex'); + paletteDialog.setAttribute('aria-hidden', 'true'); + paletteInput.value = ''; + paletteItems = []; + clearResults(); }; const onDocumentClick = (event: MouseEvent): void => { - const target = event.target; - if (!(target instanceof Element)) { + if (event.target === paletteDialog) { + closePalette(); + } +}; + +const onInputKeydown = (event: KeyboardEvent): void => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + selectItem(selectedIndex + 1); return; } - if (target.matches('[data-search-dialog]')) { - closeSearchDialog(); + if (event.key === 'ArrowUp') { + event.preventDefault(); + selectItem(selectedIndex - 1); + return; + } + + if (event.key === 'Enter') { + event.preventDefault(); + runSelectedItem(); } }; const onDocumentKeydown = (event: KeyboardEvent): void => { - const target = event.target; - const isTyping = - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target instanceof HTMLSelectElement || - (target instanceof HTMLElement && target.isContentEditable); + const commandShortcut = event.key.toLowerCase() === 'p' && (event.metaKey || event.ctrlKey); - if (event.key === 'Escape') { - closeSearchDialog(); + if (commandShortcut && !isTypingTarget(event.target)) { + event.preventDefault(); + if (isOpen()) { + closePalette(); + } else { + openPalette(); + } return; } - if (event.key === '/' && !isTyping) { + if (event.key === '/' && !isTypingTarget(event.target)) { event.preventDefault(); - openSearchDialog(); + openPalette(); + return; + } + + if (event.key === 'Escape' && isOpen()) { + event.preventDefault(); + closePalette(); } }; -const initQuickSearch = (): void => { - if (globalWindow[searchInitializedKey]) { +const initCommandPalette = (): void => { + if (globalWindow[commandPaletteInitializedKey]) { return; } - globalWindow[searchInitializedKey] = true; + globalWindow[commandPaletteInitializedKey] = true; - if (!searchInput || !searchDropdown) { + if (!paletteInput || !paletteResults || !paletteRoot) { return; } - searchOpenButtons.forEach(button => { - button.addEventListener('click', openSearchDialog); + setShortcutHints(); + paletteOpenButtons.forEach(button => { + button.addEventListener('click', openPalette); }); - searchCloseButtons.forEach(button => { - button.addEventListener('click', closeSearchDialog); + paletteCloseButtons.forEach(button => { + button.addEventListener('click', closePalette); }); - searchInput.addEventListener('input', onSearchInput); - searchInput.addEventListener('blur', onSearchBlur); + paletteInput.addEventListener('input', scheduleFetch); + paletteInput.addEventListener('keydown', onInputKeydown); document.addEventListener('click', onDocumentClick); document.addEventListener('keydown', onDocumentKeydown); }; -initQuickSearch(); +initCommandPalette(); diff --git a/templates/components/navigation.gohtml b/templates/components/navigation.gohtml index 024d5e6..d0d3625 100644 --- a/templates/components/navigation.gohtml +++ b/templates/components/navigation.gohtml @@ -10,38 +10,39 @@