feat: add watchlist toggle to search results
This commit is contained in:
@@ -15,13 +15,14 @@ import (
|
|||||||
const commandPaletteAnimeLimit = 24
|
const commandPaletteAnimeLimit = 24
|
||||||
|
|
||||||
type commandPaletteItem struct {
|
type commandPaletteItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Subtitle string `json:"subtitle"`
|
Subtitle string `json:"subtitle"`
|
||||||
Href string `json:"href"`
|
Href string `json:"href"`
|
||||||
Image string `json:"image,omitempty"`
|
Image string `json:"image,omitempty"`
|
||||||
Icon string `json:"icon,omitempty"`
|
Icon string `json:"icon,omitempty"`
|
||||||
|
InWatchlist bool `json:"inWatchlist,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type commandPaletteResponse struct {
|
type commandPaletteResponse struct {
|
||||||
@@ -50,7 +51,7 @@ func (h *AnimeHandler) HandleCommandPalette(c *gin.Context) {
|
|||||||
hasNextPage := false
|
hasNextPage := false
|
||||||
if len(query) >= 2 {
|
if len(query) >= 2 {
|
||||||
var animeItems []commandPaletteItem
|
var animeItems []commandPaletteItem
|
||||||
animeItems, hasNextPage = h.commandPaletteAnimeResults(c, query, page)
|
animeItems, hasNextPage = h.commandPaletteAnimeResults(c, user.ID, query, page)
|
||||||
items = append(items, animeItems...)
|
items = append(items, animeItems...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,22 +96,24 @@ func (h *AnimeHandler) commandPaletteNavigationItems(query string) []commandPale
|
|||||||
return filtered
|
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)
|
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, page, commandPaletteAnimeLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
animes := wrapAnimes(res.Animes)
|
animes := wrapAnimes(res.Animes)
|
||||||
|
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
||||||
items := make([]commandPaletteItem, 0, len(animes))
|
items := make([]commandPaletteItem, 0, len(animes))
|
||||||
for _, anime := range animes {
|
for _, anime := range animes {
|
||||||
items = append(items, commandPaletteItem{
|
items = append(items, commandPaletteItem{
|
||||||
ID: fmt.Sprintf("anime:%d", anime.MalID),
|
ID: fmt.Sprintf("anime:%d", anime.MalID),
|
||||||
Type: "anime",
|
Type: "anime",
|
||||||
Label: anime.DisplayTitle(),
|
Label: anime.DisplayTitle(),
|
||||||
Subtitle: strings.TrimSpace("Anime " + anime.Type),
|
Subtitle: strings.TrimSpace("Anime " + anime.Type),
|
||||||
Href: fmt.Sprintf("/anime/%d", anime.MalID),
|
Href: fmt.Sprintf("/anime/%d", anime.MalID),
|
||||||
Image: anime.ImageURL(),
|
Image: anime.ImageURL(),
|
||||||
|
InWatchlist: watchlistMap[int64(anime.MalID)],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return items, res.HasNextPage
|
return items, res.HasNextPage
|
||||||
|
|||||||
@@ -17,6 +17,21 @@ import {
|
|||||||
isSafeImageUrl,
|
isSafeImageUrl,
|
||||||
} from "./state";
|
} from "./state";
|
||||||
|
|
||||||
|
const watchlistOverrides = new Map<number, boolean>();
|
||||||
|
|
||||||
|
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 => {
|
export const setClearButtonState = (hasQuery: boolean): void => {
|
||||||
searchClearButtons.forEach((button) => {
|
searchClearButtons.forEach((button) => {
|
||||||
button.classList.toggle("opacity-0", !hasQuery);
|
button.classList.toggle("opacity-0", !hasQuery);
|
||||||
@@ -158,6 +173,40 @@ const cleanLabel = (item: CommandPaletteItem): string => {
|
|||||||
return item.label;
|
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 buildCard = (item: CommandPaletteItem, index: number): HTMLAnchorElement => {
|
||||||
const card = document.createElement("a");
|
const card = document.createElement("a");
|
||||||
card.href = item.href;
|
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.className = "relative mb-3 overflow-hidden bg-background-button";
|
||||||
media.appendChild(buildPosterImage(item));
|
media.appendChild(buildPosterImage(item));
|
||||||
|
|
||||||
|
const watchlistButton = buildWatchlistButton(item);
|
||||||
|
if (watchlistButton) {
|
||||||
|
media.appendChild(watchlistButton);
|
||||||
|
}
|
||||||
|
|
||||||
if (item.type === "continue") {
|
if (item.type === "continue") {
|
||||||
const removeButton = document.createElement("button");
|
const removeButton = document.createElement("button");
|
||||||
removeButton.type = "button";
|
removeButton.type = "button";
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface CommandPaletteItem {
|
|||||||
href: string;
|
href: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
inWatchlist?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandPaletteResponse {
|
export interface CommandPaletteResponse {
|
||||||
|
|||||||
@@ -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 (
|
const toggleWatchlist = async (
|
||||||
id: number,
|
id: number,
|
||||||
title: string,
|
title: string,
|
||||||
@@ -178,6 +182,7 @@ const toggleWatchlist = async (
|
|||||||
}
|
}
|
||||||
syncIconsForId(id);
|
syncIconsForId(id);
|
||||||
syncWatchlistDropdown(id, optimisticNext);
|
syncWatchlistDropdown(id, optimisticNext);
|
||||||
|
dispatchWatchlistChange(id, optimisticNext);
|
||||||
|
|
||||||
const url = isInWatchlist ? `/api/watchlist/${id}` : "/api/watchlist";
|
const url = isInWatchlist ? `/api/watchlist/${id}` : "/api/watchlist";
|
||||||
const method: "DELETE" | "POST" = isInWatchlist ? "DELETE" : "POST";
|
const method: "DELETE" | "POST" = isInWatchlist ? "DELETE" : "POST";
|
||||||
@@ -197,6 +202,7 @@ const toggleWatchlist = async (
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
rollback();
|
rollback();
|
||||||
|
dispatchWatchlistChange(id, isInWatchlist);
|
||||||
toast("Failed to update watchlist");
|
toast("Failed to update watchlist");
|
||||||
console.error("failed to update watchlist:", error);
|
console.error("failed to update watchlist:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -398,6 +404,7 @@ const updateWatchlist = async (
|
|||||||
watchlistStore.add(id);
|
watchlistStore.add(id);
|
||||||
syncIconsForId(id);
|
syncIconsForId(id);
|
||||||
syncRemoveButtonVisibility(id);
|
syncRemoveButtonVisibility(id);
|
||||||
|
dispatchWatchlistChange(id, true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await requestJson("/api/watchlist", {
|
const response = await requestJson("/api/watchlist", {
|
||||||
@@ -419,6 +426,7 @@ const updateWatchlist = async (
|
|||||||
toast(`Marked ${title} as ${display}`);
|
toast(`Marked ${title} as ${display}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
rollback();
|
rollback();
|
||||||
|
dispatchWatchlistChange(id, watchlistStore.has(id));
|
||||||
toast("Failed to update watchlist");
|
toast("Failed to update watchlist");
|
||||||
console.error("failed to update watchlist:", error);
|
console.error("failed to update watchlist:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -440,6 +448,7 @@ const removeWatchlist = async (id: number, title: string, source: HTMLElement):
|
|||||||
watchlistStore.remove(id);
|
watchlistStore.remove(id);
|
||||||
syncIconsForId(id);
|
syncIconsForId(id);
|
||||||
syncWatchlistDropdown(id, false);
|
syncWatchlistDropdown(id, false);
|
||||||
|
dispatchWatchlistChange(id, false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await requestJson(`/api/watchlist/${id}`, { method: "DELETE" });
|
const response = await requestJson(`/api/watchlist/${id}`, { method: "DELETE" });
|
||||||
@@ -460,6 +469,7 @@ const removeWatchlist = async (id: number, title: string, source: HTMLElement):
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
rollback();
|
rollback();
|
||||||
|
dispatchWatchlistChange(id, watchlistStore.has(id));
|
||||||
toast("Failed to update watchlist");
|
toast("Failed to update watchlist");
|
||||||
console.error("failed to update watchlist:", error);
|
console.error("failed to update watchlist:", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
Reference in New Issue
Block a user