/playlists lays its cards out as tiles, the cover across the top, four to a row and two on a phone, on the wide column: the same PlaylistCard, laid out by its list's class (.playlist-grid), so each theme's card skin carries over. It drops the "public" badge there, every playlist listed being public. On every card, a playlist without a cover shows a tile of its own colour with its initial (ThumbnailPlaceholder) in place of the 📋, and the date is "updated …" of its last dump, or "created …". A middle, ctrl, cmd or shift click anywhere on a playlist card opens it in a new tab — only its title was a link, the rest navigating by a click handler. useCardLink (hooks/shared) is useDumpCardLink's handlers for any card, which the dump cards now get through it. A profile tab's query stays with the tab — going from the playlists to the dumps kept the whole query, so a playlist category, and now ?sort=, landed on the dumps' URL. Only the dump lists pass their category to each other; any other change of tab drops the query. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
5.3 KiB
TypeScript
166 lines
5.3 KiB
TypeScript
/**
|
|
* Makes every list the API serves a page at a time work the same way. Each is a
|
|
* {@link PagedList}, whose next page `useLoadMore` appends, and this module
|
|
* reads its pages.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { DEFAULT_PAGE_SIZE } from "../../config/api.ts";
|
|
import type { PaginatedData, Wire } from "../../../shared/api.ts";
|
|
import { dataOr, expectOk } from "../shared/api.ts";
|
|
|
|
/**
|
|
* Held for a list by `usePagedList`, or by a page that loads it along with
|
|
* more, as the search page does. `useLoadMore` loads the pages after `page`.
|
|
*/
|
|
export interface PagedList<T> {
|
|
items: T[];
|
|
hasMore: boolean;
|
|
/** Loading more carries on from this page, the last loaded. */
|
|
page: number;
|
|
loadingMore: boolean;
|
|
}
|
|
|
|
/** Returns the list holding only its first page, `items`. */
|
|
export function firstPage<T>(items: T[], hasMore: boolean): PagedList<T> {
|
|
return { items, hasMore, page: 1, loadingMore: false };
|
|
}
|
|
|
|
/** Returns the list as `useFeedCache`'s `cached` restores it. */
|
|
export function restoredList<T>(
|
|
{ items, hasMore, page }: { items: T[]; hasMore: boolean; page: number },
|
|
): PagedList<T> {
|
|
return { items, hasMore, page, loadingMore: false };
|
|
}
|
|
|
|
/**
|
|
* Checks whether `list` has a next page to load now, which `useLoadMore`
|
|
* requires and `useInfiniteScroll` takes as `enabled`. A list not loaded yet
|
|
* is null.
|
|
*/
|
|
export function canLoadMore(list: PagedList<unknown> | null): boolean {
|
|
return !!list && list.hasMore && !list.loadingMore;
|
|
}
|
|
|
|
/**
|
|
* Returns a setter of a list's items as a setter of the list, for the sync
|
|
* hooks, which edit items only.
|
|
*/
|
|
export function mapItems<T>(
|
|
fn: (items: T[]) => T[],
|
|
): (list: PagedList<T>) => PagedList<T> {
|
|
return (list) => ({ ...list, items: fn(list.items) });
|
|
}
|
|
|
|
/** Returns `url` asking for page `page`, `limit` items long. */
|
|
export function pageUrl(
|
|
url: string,
|
|
page: number,
|
|
limit = DEFAULT_PAGE_SIZE,
|
|
): string {
|
|
return `${url}${url.includes("?") ? "&" : "?"}page=${page}&limit=${limit}`;
|
|
}
|
|
|
|
/**
|
|
* Returns the page `res` answers, its items deserialized.
|
|
*
|
|
* @throws {Error} With the server's message when it refuses, and when the
|
|
* body isn't an envelope.
|
|
*/
|
|
export async function readPage<T>(
|
|
res: Response,
|
|
deserialize: (wire: Wire<T>) => T,
|
|
): Promise<PaginatedData<T>> {
|
|
const { items, hasMore } = await expectOk<PaginatedData<Wire<T>>>(res);
|
|
return { items: items.map(deserialize), hasMore };
|
|
}
|
|
|
|
const EMPTY_PAGE: PaginatedData<never> = { items: [], hasMore: false };
|
|
|
|
/**
|
|
* Returns the first page `res` answers as a list, or an empty list when the
|
|
* server refuses it. The profile pages read their lists with it beside the
|
|
* user, whose failure alone fails the page.
|
|
*
|
|
* @throws {Error} When the body isn't an envelope.
|
|
*/
|
|
export async function readFirstPage<T>(
|
|
res: Response,
|
|
deserialize: (wire: Wire<T>) => T,
|
|
): Promise<PagedList<T>> {
|
|
const { items, hasMore } = await dataOr<PaginatedData<Wire<T>>>(
|
|
res,
|
|
EMPTY_PAGE,
|
|
);
|
|
return firstPage(items.map(deserialize), hasMore);
|
|
}
|
|
|
|
/**
|
|
* Returns `items` with `item` first, or `items` as they are when it is already
|
|
* there, as when a list's own echo of a new item arrives after it.
|
|
*/
|
|
export function withFirst<T extends { id: string }>(items: T[], item: T): T[] {
|
|
return items.some((i) => i.id === item.id) ? items : [item, ...items];
|
|
}
|
|
|
|
/**
|
|
* Returns `items`, in the order of `compare` as the API pages them, with
|
|
* `item` where that order puts it: before the first item that doesn't come
|
|
* before it. An item that comes after every one loaded is left to a later
|
|
* page, and `items` returned as they are, as they are when `item` is already
|
|
* there. `usePlaylistListSync` puts a new, newly visible or risen playlist in
|
|
* a list with it, whatever its order.
|
|
*/
|
|
export function withInOrder<T extends { id: string }>(
|
|
items: T[],
|
|
item: T,
|
|
compare: (a: T, b: T) => number,
|
|
): T[] {
|
|
if (items.some((i) => i.id === item.id)) return items;
|
|
if (items.length === 0) return [item];
|
|
const idx = items.findIndex((i) => compare(i, item) >= 0);
|
|
if (idx === -1) return items;
|
|
return [...items.slice(0, idx), item, ...items.slice(idx)];
|
|
}
|
|
|
|
/**
|
|
* Returns `items`, newest first, with `item` where its `createdAt` puts it,
|
|
* as {@link withInOrder} does. `useDumpListSync` puts a new or newly visible
|
|
* dump in a list with it.
|
|
*/
|
|
export function withByDate<T extends { id: string; createdAt: Date }>(
|
|
items: T[],
|
|
item: T,
|
|
): T[] {
|
|
return withInOrder(
|
|
items,
|
|
item,
|
|
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetches pages 1 to `pages` of a list again, and resolves to their items,
|
|
* each once, and whether more follow. An item added between two pages'
|
|
* fetches shifts the later page by one, which would list an item twice. A
|
|
* live list calls it after a reconnection, through `usePagedList`'s `resync`
|
|
* or, for the search's dumps, directly; it rejects when a page fails.
|
|
*/
|
|
export async function refetchPages<T extends { id: string }>(
|
|
pages: number,
|
|
fetchPage: (page: number) => Promise<PaginatedData<T>>,
|
|
): Promise<PaginatedData<T>> {
|
|
const results = await Promise.all(
|
|
Array.from({ length: pages }, (_, i) => fetchPage(i + 1)),
|
|
);
|
|
const byId = new Map<string, T>();
|
|
for (const item of results.flatMap((r) => r.items)) {
|
|
if (!byId.has(item.id)) byId.set(item.id, item);
|
|
}
|
|
return {
|
|
items: [...byId.values()],
|
|
hasMore: results[results.length - 1].hasMore,
|
|
};
|
|
}
|