feat: add watch order mode toggle
This commit is contained in:
@@ -20,6 +20,22 @@ const chiakiWatchOrderURL = "https://chiaki.site/?/tools/watch_order/id/%d"
|
|||||||
const watchOrderCacheTTL = time.Hour * 24
|
const watchOrderCacheTTL = time.Hour * 24
|
||||||
const maxWatchOrderEntries = 120 // cap to prevent huge relation chains
|
const maxWatchOrderEntries = 120 // cap to prevent huge relation chains
|
||||||
|
|
||||||
|
type WatchOrderMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
WatchOrderModeMain WatchOrderMode = "main"
|
||||||
|
WatchOrderModeComplete WatchOrderMode = "complete"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NormalizeWatchOrderMode(value string) WatchOrderMode {
|
||||||
|
switch WatchOrderMode(strings.ToLower(strings.TrimSpace(value))) {
|
||||||
|
case WatchOrderModeComplete:
|
||||||
|
return WatchOrderModeComplete
|
||||||
|
default:
|
||||||
|
return WatchOrderModeMain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// watchOrderTypeLabel normalizes watch order type to display-friendly labels.
|
// watchOrderTypeLabel normalizes watch order type to display-friendly labels.
|
||||||
func watchOrderTypeLabel(value string) string {
|
func watchOrderTypeLabel(value string) string {
|
||||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||||
@@ -28,17 +44,35 @@ func watchOrderTypeLabel(value string) string {
|
|||||||
return "TV"
|
return "TV"
|
||||||
case "movie":
|
case "movie":
|
||||||
return "Movie"
|
return "Movie"
|
||||||
|
case "ona":
|
||||||
|
return "ONA"
|
||||||
|
case "ova":
|
||||||
|
return "OVA"
|
||||||
default:
|
default:
|
||||||
return strings.TrimSpace(value)
|
return strings.TrimSpace(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAllowedWatchOrderType returns true only for TV and Movie types (filters out specials, etc).
|
func isTVWatchOrderType(value string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(value), "tv")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllowedWatchOrderType returns true for the default uncluttered watch order types.
|
||||||
func isAllowedWatchOrderType(value string) bool {
|
func isAllowedWatchOrderType(value string) bool {
|
||||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||||
return normalized == "tv" || normalized == "movie"
|
return normalized == "tv" || normalized == "movie"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasTVWatchOrderEntry(entries []watchorder.WatchOrderEntry) bool {
|
||||||
|
for _, entry := range entries {
|
||||||
|
if isTVWatchOrderType(entry.Type) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func relationCacheKey(id int) string {
|
func relationCacheKey(id int) string {
|
||||||
return fmt.Sprintf("relations:watch-order:%d", id)
|
return fmt.Sprintf("relations:watch-order:%d", id)
|
||||||
}
|
}
|
||||||
@@ -166,15 +200,19 @@ func (c *Client) handleWatchOrderError(ctx context.Context, id int, err error) (
|
|||||||
return c.currentOnlyRelation(ctx, id)
|
return c.currentOnlyRelation(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildAllowedWatchOrderEntries(result watchorder.WatchOrderResult) ([]watchorder.WatchOrderEntry, map[int]bool) {
|
func buildAllowedWatchOrderEntries(result watchorder.WatchOrderResult, mode WatchOrderMode) ([]watchorder.WatchOrderEntry, map[int]bool) {
|
||||||
allowedEntries := make([]watchorder.WatchOrderEntry, 0, len(result.WatchOrder))
|
allowedEntries := make([]watchorder.WatchOrderEntry, 0, len(result.WatchOrder))
|
||||||
seen := make(map[int]bool)
|
seen := make(map[int]bool)
|
||||||
|
shouldIncludeAllTypes := mode == WatchOrderModeComplete || !hasTVWatchOrderEntry(result.WatchOrder)
|
||||||
|
|
||||||
for _, entry := range result.WatchOrder {
|
for _, entry := range result.WatchOrder {
|
||||||
if len(allowedEntries) >= maxWatchOrderEntries {
|
if len(allowedEntries) >= maxWatchOrderEntries {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if !isAllowedWatchOrderType(entry.Type) || seen[entry.ID] {
|
if seen[entry.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !shouldIncludeAllTypes && !isAllowedWatchOrderType(entry.Type) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,13 +305,13 @@ type fetchResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetFullRelations returns related anime based on watch order, with parallel fetching (3 concurrent).
|
// GetFullRelations returns related anime based on watch order, with parallel fetching (3 concurrent).
|
||||||
func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry, error) {
|
func (c *Client) GetFullRelations(ctx context.Context, id int, mode WatchOrderMode) ([]RelationEntry, error) {
|
||||||
result, err := c.getWatchOrder(ctx, id)
|
result, err := c.getWatchOrder(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.handleWatchOrderError(ctx, id, err)
|
return c.handleWatchOrderError(ctx, id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
allowedEntries, seen := buildAllowedWatchOrderEntries(result)
|
allowedEntries, seen := buildAllowedWatchOrderEntries(result, mode)
|
||||||
fetched := c.fetchRelationResults(ctx, allowedEntries)
|
fetched := c.fetchRelationResults(ctx, allowedEntries)
|
||||||
relations := buildRelationsFromResults(fetched, id)
|
relations := buildRelationsFromResults(fetched, id)
|
||||||
relations, err = c.ensureCurrentRelation(ctx, id, seen, relations)
|
relations, err = c.ensureCurrentRelation(ctx, id, seen, relations)
|
||||||
@@ -290,6 +328,6 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
|
|||||||
|
|
||||||
func (c *Client) WarmFullRelations(id int) {
|
func (c *Client) WarmFullRelations(id int) {
|
||||||
c.runAsyncRefresh(func(ctx context.Context) {
|
c.runAsyncRefresh(func(ctx context.Context) {
|
||||||
_, _ = c.GetFullRelations(ctx, id)
|
_, _ = c.GetFullRelations(ctx, id, WatchOrderModeMain)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package jikan
|
package jikan
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"mal/integrations/watchorder"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func runBoolCases(t *testing.T, tests []struct {
|
func runBoolCases(t *testing.T, tests []struct {
|
||||||
name string
|
name string
|
||||||
@@ -36,6 +39,138 @@ func TestIsAllowedWatchOrderType(t *testing.T) {
|
|||||||
runBoolCases(t, tests, isAllowedWatchOrderType)
|
runBoolCases(t, tests, isAllowedWatchOrderType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeWatchOrderMode(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want WatchOrderMode
|
||||||
|
}{
|
||||||
|
{name: "empty defaults main", input: "", want: WatchOrderModeMain},
|
||||||
|
{name: "main", input: "main", want: WatchOrderModeMain},
|
||||||
|
{name: "complete", input: "complete", want: WatchOrderModeComplete},
|
||||||
|
{name: "case and whitespace", input: " COMPLETE ", want: WatchOrderModeComplete},
|
||||||
|
{name: "unknown defaults main", input: "everything", want: WatchOrderModeMain},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range tests {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
got := NormalizeWatchOrderMode(testCase.input)
|
||||||
|
if got != testCase.want {
|
||||||
|
t.Fatalf("expected %q, got %q", testCase.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasTVWatchOrderEntry(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
entries []watchorder.WatchOrderEntry
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "contains tv",
|
||||||
|
entries: []watchorder.WatchOrderEntry{
|
||||||
|
{ID: 1, Type: "Movie"},
|
||||||
|
{ID: 2, Type: " TV "},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ona only",
|
||||||
|
entries: []watchorder.WatchOrderEntry{
|
||||||
|
{ID: 1, Type: "ONA"},
|
||||||
|
{ID: 2, Type: "Special"},
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range tests {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
got := hasTVWatchOrderEntry(testCase.entries)
|
||||||
|
if got != testCase.want {
|
||||||
|
t.Fatalf("expected %v, got %v", testCase.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAllowedWatchOrderEntriesKeepsDefaultTypesWhenTVExists(t *testing.T) {
|
||||||
|
result := watchorder.WatchOrderResult{
|
||||||
|
WatchOrder: []watchorder.WatchOrderEntry{
|
||||||
|
{ID: 1, Type: "TV"},
|
||||||
|
{ID: 2, Type: "Special"},
|
||||||
|
{ID: 3, Type: "Movie"},
|
||||||
|
{ID: 4, Type: "ONA"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, seen := buildAllowedWatchOrderEntries(result, WatchOrderModeMain)
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries[0].ID != 1 || entries[1].ID != 3 {
|
||||||
|
t.Fatalf("unexpected entries: %+v", entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !seen[1] || !seen[3] || seen[2] || seen[4] {
|
||||||
|
t.Fatalf("unexpected seen map: %+v", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAllowedWatchOrderEntriesIncludesAllTypesWhenNoTVExists(t *testing.T) {
|
||||||
|
result := watchorder.WatchOrderResult{
|
||||||
|
WatchOrder: []watchorder.WatchOrderEntry{
|
||||||
|
{ID: 1, Type: "ONA"},
|
||||||
|
{ID: 2, Type: "Special"},
|
||||||
|
{ID: 3, Type: "Movie"},
|
||||||
|
{ID: 1, Type: "ONA"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, seen := buildAllowedWatchOrderEntries(result, WatchOrderModeMain)
|
||||||
|
if len(entries) != 3 {
|
||||||
|
t.Fatalf("expected 3 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries[0].ID != 1 || entries[1].ID != 2 || entries[2].ID != 3 {
|
||||||
|
t.Fatalf("unexpected entries: %+v", entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !seen[1] || !seen[2] || !seen[3] {
|
||||||
|
t.Fatalf("unexpected seen map: %+v", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAllowedWatchOrderEntriesIncludesAllTypesInCompleteMode(t *testing.T) {
|
||||||
|
result := watchorder.WatchOrderResult{
|
||||||
|
WatchOrder: []watchorder.WatchOrderEntry{
|
||||||
|
{ID: 1, Type: "TV"},
|
||||||
|
{ID: 2, Type: "Special"},
|
||||||
|
{ID: 3, Type: "ONA"},
|
||||||
|
{ID: 4, Type: "Movie"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, seen := buildAllowedWatchOrderEntries(result, WatchOrderModeComplete)
|
||||||
|
if len(entries) != 4 {
|
||||||
|
t.Fatalf("expected 4 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, entry := range entries {
|
||||||
|
wantID := index + 1
|
||||||
|
if entry.ID != wantID {
|
||||||
|
t.Fatalf("expected entry %d to have id %d, got %+v", index, wantID, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !seen[1] || !seen[2] || !seen[3] || !seen[4] {
|
||||||
|
t.Fatalf("unexpected seen map: %+v", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWatchOrderTypeLabel(t *testing.T) {
|
func TestWatchOrderTypeLabel(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -44,6 +179,8 @@ func TestWatchOrderTypeLabel(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{name: "tv", input: "tv", want: "TV"},
|
{name: "tv", input: "tv", want: "TV"},
|
||||||
{name: "movie", input: "movie", want: "Movie"},
|
{name: "movie", input: "movie", want: "Movie"},
|
||||||
|
{name: "ona", input: "ona", want: "ONA"},
|
||||||
|
{name: "ova", input: "ova", want: "OVA"},
|
||||||
{name: "trimmed passthrough", input: " tv special ", want: "tv special"},
|
{name: "trimmed passthrough", input: " tv special ", want: "tv special"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
117
static/search.ts
117
static/search.ts
@@ -8,6 +8,12 @@ interface CommandPaletteItem {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CommandPaletteResponse {
|
||||||
|
items: CommandPaletteItem[];
|
||||||
|
hasNextPage: boolean;
|
||||||
|
nextPage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
const commandPaletteInitializedKey = Symbol("commandPaletteInitialized");
|
const commandPaletteInitializedKey = Symbol("commandPaletteInitialized");
|
||||||
const globalWindow = window as Window & { [commandPaletteInitializedKey]?: boolean };
|
const globalWindow = window as Window & { [commandPaletteInitializedKey]?: boolean };
|
||||||
|
|
||||||
@@ -27,8 +33,11 @@ let selectedIndex = 0;
|
|||||||
let fetchTimeout: number | undefined;
|
let fetchTimeout: number | undefined;
|
||||||
let lastQuery = "";
|
let lastQuery = "";
|
||||||
let activeRequestController: AbortController | undefined;
|
let activeRequestController: AbortController | undefined;
|
||||||
|
let nextSearchPage: number | undefined;
|
||||||
|
let hasNextSearchPage = false;
|
||||||
|
let isFetchingNextPage = false;
|
||||||
let lastFocusedSearchOpener: HTMLElement | null = null;
|
let lastFocusedSearchOpener: HTMLElement | null = null;
|
||||||
const responseCache = new Map<string, CommandPaletteItem[]>();
|
const responseCache = new Map<string, CommandPaletteResponse>();
|
||||||
|
|
||||||
const iconPaths: Record<string, string> = {
|
const iconPaths: Record<string, string> = {
|
||||||
bookmark: "M19 21l-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z",
|
bookmark: "M19 21l-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z",
|
||||||
@@ -136,6 +145,9 @@ const buildPosterImage = (item: CommandPaletteItem): HTMLElement => {
|
|||||||
const clearResults = (): void => {
|
const clearResults = (): void => {
|
||||||
resultItems = [];
|
resultItems = [];
|
||||||
selectedIndex = 0;
|
selectedIndex = 0;
|
||||||
|
nextSearchPage = undefined;
|
||||||
|
hasNextSearchPage = false;
|
||||||
|
isFetchingNextPage = false;
|
||||||
searchResults?.replaceChildren();
|
searchResults?.replaceChildren();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -409,6 +421,20 @@ const renderItems = (items: CommandPaletteItem[]): void => {
|
|||||||
selectItem(0, false);
|
selectItem(0, false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const appendItems = (items: CommandPaletteItem[]): void => {
|
||||||
|
if (!searchResults || items.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIDs = new Set(resultItems.map((item) => item.id));
|
||||||
|
const nextItems = items.filter((item) => !existingIDs.has(item.id));
|
||||||
|
if (nextItems.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderItems([...resultItems, ...nextItems]);
|
||||||
|
};
|
||||||
|
|
||||||
const visibleSearchItems = (items: CommandPaletteItem[], query: string): CommandPaletteItem[] => {
|
const visibleSearchItems = (items: CommandPaletteItem[], query: string): CommandPaletteItem[] => {
|
||||||
if (query === "") {
|
if (query === "") {
|
||||||
return [];
|
return [];
|
||||||
@@ -417,6 +443,23 @@ const visibleSearchItems = (items: CommandPaletteItem[], query: string): Command
|
|||||||
return items.filter((item) => item.type === "anime");
|
return items.filter((item) => item.type === "anime");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseCommandPaletteResponse = (payload: unknown): CommandPaletteResponse => {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return { items: payload as CommandPaletteItem[], hasNextPage: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload && typeof payload === "object" && Array.isArray((payload as CommandPaletteResponse).items)) {
|
||||||
|
const response = payload as CommandPaletteResponse;
|
||||||
|
return {
|
||||||
|
items: response.items,
|
||||||
|
hasNextPage: response.hasNextPage,
|
||||||
|
nextPage: response.nextPage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items: [], hasNextPage: false };
|
||||||
|
};
|
||||||
|
|
||||||
const renderPendingQuery = (query: string): void => {
|
const renderPendingQuery = (query: string): void => {
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return;
|
return;
|
||||||
@@ -441,7 +484,9 @@ const fetchSearchItems = (query: string): void => {
|
|||||||
|
|
||||||
const cached = responseCache.get(query);
|
const cached = responseCache.get(query);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
renderItems(cached);
|
nextSearchPage = cached.nextPage;
|
||||||
|
hasNextSearchPage = cached.hasNextPage;
|
||||||
|
renderItems(visibleSearchItems(cached.items, query));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,18 +498,21 @@ const fetchSearchItems = (query: string): void => {
|
|||||||
fetch("/api/command-palette?q=" + encodeURIComponent(query), { signal: controller.signal })
|
fetch("/api/command-palette?q=" + encodeURIComponent(query), { signal: controller.signal })
|
||||||
.then((res: Response) => {
|
.then((res: Response) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return [];
|
return { items: [], hasNextPage: false };
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
})
|
})
|
||||||
.then((items: CommandPaletteItem[]) => {
|
.then((payload: unknown) => {
|
||||||
if (controller.signal.aborted || query !== lastQuery) {
|
if (controller.signal.aborted || query !== lastQuery) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleItems = visibleSearchItems(items, query);
|
const response = parseCommandPaletteResponse(payload);
|
||||||
|
const visibleItems = visibleSearchItems(response.items, query);
|
||||||
activeRequestController = undefined;
|
activeRequestController = undefined;
|
||||||
responseCache.set(query, visibleItems);
|
nextSearchPage = response.nextPage;
|
||||||
|
hasNextSearchPage = response.hasNextPage;
|
||||||
|
responseCache.set(query, response);
|
||||||
renderItems(visibleItems);
|
renderItems(visibleItems);
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
@@ -478,6 +526,62 @@ const fetchSearchItems = (query: string): void => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchNextSearchPage = (): void => {
|
||||||
|
if (!lastQuery || !hasNextSearchPage || !nextSearchPage || isFetchingNextPage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isFetchingNextPage = true;
|
||||||
|
const query = lastQuery;
|
||||||
|
const page = nextSearchPage;
|
||||||
|
|
||||||
|
fetch(
|
||||||
|
"/api/command-palette?q=" + encodeURIComponent(query) + "&page=" + encodeURIComponent(String(page)),
|
||||||
|
)
|
||||||
|
.then((res: Response) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
return { items: [], hasNextPage: false };
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((payload: unknown) => {
|
||||||
|
if (query !== lastQuery) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = parseCommandPaletteResponse(payload);
|
||||||
|
const visibleItems = visibleSearchItems(response.items, query);
|
||||||
|
const cached = responseCache.get(query);
|
||||||
|
if (cached) {
|
||||||
|
responseCache.set(query, {
|
||||||
|
items: [...cached.items, ...response.items],
|
||||||
|
hasNextPage: response.hasNextPage,
|
||||||
|
nextPage: response.nextPage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
nextSearchPage = response.nextPage;
|
||||||
|
hasNextSearchPage = response.hasNextPage;
|
||||||
|
appendItems(visibleItems);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error("Search overlay pagination error:", err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isFetchingNextPage = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResultsScroll = (): void => {
|
||||||
|
if (!searchResults) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingScroll = searchResults.scrollHeight - searchResults.scrollTop - searchResults.clientHeight;
|
||||||
|
if (remainingScroll < 480) {
|
||||||
|
fetchNextSearchPage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const scheduleFetch = (): void => {
|
const scheduleFetch = (): void => {
|
||||||
if (fetchTimeout) {
|
if (fetchTimeout) {
|
||||||
window.clearTimeout(fetchTimeout);
|
window.clearTimeout(fetchTimeout);
|
||||||
@@ -606,6 +710,7 @@ const initSearchOverlay = (): void => {
|
|||||||
});
|
});
|
||||||
searchInput.addEventListener("input", scheduleFetch);
|
searchInput.addEventListener("input", scheduleFetch);
|
||||||
searchInput.addEventListener("keydown", onInputKeydown);
|
searchInput.addEventListener("keydown", onInputKeydown);
|
||||||
|
searchResults.addEventListener("scroll", onResultsScroll);
|
||||||
document.addEventListener("click", onDocumentClick);
|
document.addEventListener("click", onDocumentClick);
|
||||||
document.addEventListener("keydown", onDocumentKeydown);
|
document.addEventListener("keydown", onDocumentKeydown);
|
||||||
searchDialog?.setAttribute("aria-hidden", "true");
|
searchDialog?.setAttribute("aria-hidden", "true");
|
||||||
|
|||||||
@@ -341,7 +341,7 @@
|
|||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div
|
<div
|
||||||
hx-get="/api/watch-order?animeId={{$anime.MalID}}"
|
hx-get="/api/watch-order?animeId={{$anime.MalID}}&mode=main"
|
||||||
hx-trigger="load"
|
hx-trigger="load"
|
||||||
>
|
>
|
||||||
<div class="mt-8 flex items-center gap-3 text-foreground-muted">
|
<div class="mt-8 flex items-center gap-3 text-foreground-muted">
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
{{define "watch_order"}}
|
{{define "watch_order"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4" id="watch-order-section">
|
||||||
<h2 class="text-base font-normal text-foreground">Watch Order</h2>
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<h2 class="text-base font-normal text-foreground">Watch Order</h2>
|
||||||
|
{{if ne .Mode "complete"}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bg-background-button px-3 py-1.5 text-xs font-normal text-foreground-muted transition-colors hover:bg-background-button-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent"
|
||||||
|
hx-get="/api/watch-order?animeId={{.AnimeID}}&mode=complete"
|
||||||
|
hx-target="#watch-order-section"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
Show complete list
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bg-background-button px-3 py-1.5 text-xs font-normal text-foreground-muted transition-colors hover:bg-background-button-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent"
|
||||||
|
hx-get="/api/watch-order?animeId={{.AnimeID}}&mode=main"
|
||||||
|
hx-target="#watch-order-section"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
Show main list
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 2xl:grid-cols-6">
|
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 2xl:grid-cols-6">
|
||||||
{{range .Relations}}
|
{{range .Relations}}
|
||||||
<div class="group relative">
|
<div class="group relative">
|
||||||
@@ -21,7 +44,7 @@
|
|||||||
|
|
||||||
{{define "watch_order_loading"}}
|
{{define "watch_order_loading"}}
|
||||||
<div
|
<div
|
||||||
hx-get="/api/watch-order?animeId={{.AnimeID}}"
|
hx-get="/api/watch-order?animeId={{.AnimeID}}&mode={{if .Mode}}{{.Mode}}{{else}}main{{end}}"
|
||||||
hx-trigger="load delay:1500ms"
|
hx-trigger="load delay:1500ms"
|
||||||
>
|
>
|
||||||
<div class="mt-8 flex items-center gap-3 text-foreground-muted">
|
<div class="mt-8 flex items-center gap-3 text-foreground-muted">
|
||||||
|
|||||||
Reference in New Issue
Block a user