diff --git a/internal/anime/command_palette.go b/internal/anime/command_palette.go index 8507503..1bf9b6e 100644 --- a/internal/anime/command_palette.go +++ b/internal/anime/command_palette.go @@ -15,13 +15,14 @@ import ( 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"` + 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"` + InWatchlist bool `json:"inWatchlist,omitempty"` } type commandPaletteResponse struct { @@ -50,7 +51,7 @@ func (h *AnimeHandler) HandleCommandPalette(c *gin.Context) { hasNextPage := false if len(query) >= 2 { var animeItems []commandPaletteItem - animeItems, hasNextPage = h.commandPaletteAnimeResults(c, query, page) + animeItems, hasNextPage = h.commandPaletteAnimeResults(c, user.ID, query, page) items = append(items, animeItems...) } @@ -95,22 +96,24 @@ func (h *AnimeHandler) commandPaletteNavigationItems(query string) []commandPale return filtered } -func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string, page int) ([]commandPaletteItem, bool) { +func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, userID string, 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) + watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, 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(), + 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(), + InWatchlist: watchlistMap[int64(anime.MalID)], }) } return items, res.HasNextPage diff --git a/static/search/render.ts b/static/search/render.ts index e739500..3730337 100644 --- a/static/search/render.ts +++ b/static/search/render.ts @@ -17,6 +17,21 @@ import { isSafeImageUrl, } from "./state"; +const watchlistOverrides = new Map(); + +window.addEventListener("watchlist:change", (event) => { + if (!(event instanceof CustomEvent)) { + return; + } + + const detail = event.detail as { id?: unknown; inWatchlist?: unknown } | null; + if (!detail || typeof detail.id !== "number" || typeof detail.inWatchlist !== "boolean") { + return; + } + + watchlistOverrides.set(detail.id, detail.inWatchlist); +}); + export const setClearButtonState = (hasQuery: boolean): void => { searchClearButtons.forEach((button) => { button.classList.toggle("opacity-0", !hasQuery); @@ -158,6 +173,40 @@ const cleanLabel = (item: CommandPaletteItem): string => { return item.label; }; +const animeMalID = (item: CommandPaletteItem): number | null => { + if (item.type !== "anime") { + return null; + } + + const match = item.id.match(/^anime:(\d+)$/); + if (!match) { + return null; + } + + const id = Number.parseInt(match[1], 10); + return Number.isFinite(id) ? id : null; +}; + +const buildWatchlistButton = (item: CommandPaletteItem): HTMLButtonElement | null => { + const malID = animeMalID(item); + if (malID === null) { + return null; + } + + const button = document.createElement("button"); + const inWatchlist = watchlistOverrides.get(malID) ?? item.inWatchlist === true; + button.type = "button"; + button.className = + "absolute bottom-5 left-5 z-20 text-accent opacity-0 transition hover:text-accent/80 group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent disabled:opacity-50"; + button.dataset.watchlistToggle = ""; + button.dataset.malId = String(malID); + button.dataset.watchlistTitle = cleanLabel(item); + button.dataset.watchlistState = inWatchlist ? "in" : "out"; + button.setAttribute("aria-label", inWatchlist ? "Remove from Watchlist" : "Add to Watchlist"); + button.appendChild(buildSvgIcon(iconPaths.bookmark, "size-6 watchlist-icon")); + return button; +}; + const buildCard = (item: CommandPaletteItem, index: number): HTMLAnchorElement => { const card = document.createElement("a"); card.href = item.href; @@ -174,6 +223,11 @@ const buildCard = (item: CommandPaletteItem, index: number): HTMLAnchorElement = media.className = "relative mb-3 overflow-hidden bg-background-button"; media.appendChild(buildPosterImage(item)); + const watchlistButton = buildWatchlistButton(item); + if (watchlistButton) { + media.appendChild(watchlistButton); + } + if (item.type === "continue") { const removeButton = document.createElement("button"); removeButton.type = "button"; diff --git a/static/search/state.ts b/static/search/state.ts index 19f02aa..4a9764f 100644 --- a/static/search/state.ts +++ b/static/search/state.ts @@ -6,6 +6,7 @@ export interface CommandPaletteItem { href: string; image?: string; icon?: string; + inWatchlist?: boolean; } export interface CommandPaletteResponse { diff --git a/static/watchlist.ts b/static/watchlist.ts index f9b4d16..699bbf2 100644 --- a/static/watchlist.ts +++ b/static/watchlist.ts @@ -149,6 +149,10 @@ const closeClosestDropdown = (from: HTMLElement): void => { }); }; +const dispatchWatchlistChange = (id: number, inWatchlist: boolean): void => { + window.dispatchEvent(new CustomEvent("watchlist:change", { detail: { id, inWatchlist } })); +}; + const toggleWatchlist = async ( id: number, title: string, @@ -178,6 +182,7 @@ const toggleWatchlist = async ( } syncIconsForId(id); syncWatchlistDropdown(id, optimisticNext); + dispatchWatchlistChange(id, optimisticNext); const url = isInWatchlist ? `/api/watchlist/${id}` : "/api/watchlist"; const method: "DELETE" | "POST" = isInWatchlist ? "DELETE" : "POST"; @@ -197,6 +202,7 @@ const toggleWatchlist = async ( } } catch (error) { rollback(); + dispatchWatchlistChange(id, isInWatchlist); toast("Failed to update watchlist"); console.error("failed to update watchlist:", error); throw error; @@ -398,6 +404,7 @@ const updateWatchlist = async ( watchlistStore.add(id); syncIconsForId(id); syncRemoveButtonVisibility(id); + dispatchWatchlistChange(id, true); try { const response = await requestJson("/api/watchlist", { @@ -419,6 +426,7 @@ const updateWatchlist = async ( toast(`Marked ${title} as ${display}`); } catch (error) { rollback(); + dispatchWatchlistChange(id, watchlistStore.has(id)); toast("Failed to update watchlist"); console.error("failed to update watchlist:", error); throw error; @@ -440,6 +448,7 @@ const removeWatchlist = async (id: number, title: string, source: HTMLElement): watchlistStore.remove(id); syncIconsForId(id); syncWatchlistDropdown(id, false); + dispatchWatchlistChange(id, false); try { const response = await requestJson(`/api/watchlist/${id}`, { method: "DELETE" }); @@ -460,6 +469,7 @@ const removeWatchlist = async (id: number, title: string, source: HTMLElement): } } catch (error) { rollback(); + dispatchWatchlistChange(id, watchlistStore.has(id)); toast("Failed to update watchlist"); console.error("failed to update watchlist:", error); throw error;