package anime import ( "fmt" "mal/internal/db" "mal/internal/domain" "mal/internal/server" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" ) const commandPaletteAnimeLimit = 24 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"` } type commandPaletteResponse struct { Items []commandPaletteItem `json:"items"` HasNextPage bool `json:"hasNextPage"` NextPage int `json:"nextPage,omitempty"` } func (h *AnimeHandler) HandleCommandPalette(c *gin.Context) { user := server.CurrentUser(c) if user == nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } query := strings.TrimSpace(c.Query("q")) page, err := strconv.Atoi(c.DefaultQuery("page", "1")) if err != nil || page < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid page"}) return } items := make([]commandPaletteItem, 0, commandPaletteAnimeLimit) if query != "" { hasNextPage := false if len(query) >= 2 { var animeItems []commandPaletteItem animeItems, hasNextPage = h.commandPaletteAnimeResults(c, query, page) items = append(items, animeItems...) } if page == 1 { items = append(items, h.commandPaletteNavigationItems(query)...) items = append(items, h.commandPaletteContinueItems(c, user.ID, query)...) items = append(items, h.commandPalettePersonalItems(c, user.ID, query)...) } c.JSON(http.StatusOK, commandPaletteResponse{ Items: items, HasNextPage: hasNextPage, NextPage: page + 1, }) return } items = append(items, h.commandPaletteContinueItems(c, user.ID, query)...) items = append(items, h.commandPaletteNavigationItems(query)...) items = append(items, h.commandPalettePersonalItems(c, user.ID, query)...) c.JSON(http.StatusOK, commandPaletteResponse{Items: items}) } func (h *AnimeHandler) commandPaletteNavigationItems(query string) []commandPaletteItem { all := []commandPaletteItem{ {ID: "nav:home", Type: "navigation", Label: "Go to Home", Subtitle: "Navigation", Href: "/", Icon: "home"}, {ID: "nav:watchlist", Type: "navigation", Label: "Go to Watchlist", Subtitle: "Navigation", Href: "/watchlist", Icon: "bookmark"}, {ID: "nav:top-picks", Type: "navigation", Label: "Open Top Picks", Subtitle: "Navigation", Href: "/top-picks", Icon: "sparkles"}, {ID: "nav:popular", Type: "navigation", Label: "Browse popular", Subtitle: "Browse", Href: "/browse?order_by=popularity&sort=asc", Icon: "trending"}, {ID: "nav:airing", Type: "navigation", Label: "Currently airing", Subtitle: "Browse", Href: "/browse?status=airing&order_by=popularity&sort=asc", 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, page int) ([]commandPaletteItem, bool) { res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, page, commandPaletteAnimeLimit) if err != nil { return nil, false } animes := wrapAnimes(res.Animes) items := make([]commandPaletteItem, 0, len(animes)) for _, anime := range 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, res.HasNextPage } func (h *AnimeHandler) commandPalettePersonalItems(c *gin.Context, userID string, query string) []commandPaletteItem { items := make([]commandPaletteItem, 0, 5) watchlist, err := h.watchlistSvc.GetCommandPaletteWatchlist(c.Request.Context(), userID, query, 5) if err != nil { return items } for _, entry := range watchlist { title := watchlistTitle(entry) 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, }) if len(items) >= 5 { return items } } return items } func (h *AnimeHandler) commandPaletteContinueItems(c *gin.Context, userID string, query string) []commandPaletteItem { items := make([]commandPaletteItem, 0, 5) rows, err := h.watchlistSvc.GetCommandPaletteContinueWatching(c.Request.Context(), userID, query, 5) if err != nil { return items } for _, row := range rows { title := continueWatchingTitle(row) 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, }) if len(items) >= 5 { return items } } 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 { return row.DisplayTitle() } func watchlistTitle(row domain.UserWatchListRow) string { return row.DisplayTitle() } func watchlistStatusLabel(status string) string { switch status { case "watching": return "Watching" case "plan_to_watch": return "Plan to Watch" default: return "Watchlist" } }