feat: replace quick search with command palette
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user