diff --git a/web/src/lib/components/profile/pages.ts b/web/src/lib/components/profile/pages.ts index 63b12e53..de1d044e 100644 --- a/web/src/lib/components/profile/pages.ts +++ b/web/src/lib/components/profile/pages.ts @@ -1,5 +1,5 @@ // page fetchers for the profile tabs, shared between the route load (first -// page) and the tab components (load more). identities come from the enrich +// page) and the tab components (pagination). identities come from the enrich // sidecar's minidoc payloads, resolveMiniDoc only fires for sidecar misses import type { BobbinContext } from "$lib/api/client"; diff --git a/web/src/lib/components/profile/pagination.svelte.ts b/web/src/lib/components/profile/pagination.svelte.ts new file mode 100644 index 00000000..1856cf32 --- /dev/null +++ b/web/src/lib/components/profile/pagination.svelte.ts @@ -0,0 +1,59 @@ +export interface CursorPage { + items: T[]; + cursor?: C; +} + +type CursorPagerState = + | { kind: "at-page"; page: number } + | { kind: "loading"; page: number } + | { kind: "failed"; page: number }; + +export const pageCount = (total: number, limit: number): number => + Math.max(1, Math.ceil(Math.max(total, 0) / limit)); + +// search uses this since there isn't a known limit +export const discoveredPageCount = (page: number, hasNext: boolean): number => + Math.max(1, page + (hasNext ? 1 : 0)); + +export const createCursorPager = ( + initial: CursorPage, + load: (cursor: C) => Promise>, + hasNext: (cursor: C | undefined) => boolean = (cursor) => cursor !== undefined +) => { + let state = $state({ kind: "at-page", page: 1 }); + let pages = $state>>({ 1: initial }); + + const select = async (target: number) => { + if (state.kind === "loading" || target < 1 || target === state.page) return; + + const currentPage = state.page; + state = { kind: "loading", page: currentPage }; + try { + const loaded = { ...pages }; + for (let next = 2; next <= target; next++) { + if (loaded[next]) continue; + const cursor = loaded[next - 1]?.cursor; + if (!hasNext(cursor)) break; + loaded[next] = await load(cursor as C); + } + + pages = loaded; + state = { kind: "at-page", page: loaded[target] ? target : currentPage }; + } catch { + state = { kind: "failed", page: currentPage }; + } + }; + + return { + get state() { + return state; + }, + get items() { + return pages[state.page]?.items ?? []; + }, + get hasNext() { + return hasNext(pages[state.page]?.cursor); + }, + select + }; +}; diff --git a/web/src/lib/components/profile/pagination.test.ts b/web/src/lib/components/profile/pagination.test.ts new file mode 100644 index 00000000..b5537b69 --- /dev/null +++ b/web/src/lib/components/profile/pagination.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { createCursorPager, discoveredPageCount, pageCount } from "./pagination.svelte"; + +describe("profile pagination counts", () => { + it("uses the known total for profile lists", () => { + expect(pageCount(4, 30)).toBe(1); + expect(pageCount(30, 30)).toBe(1); + expect(pageCount(31, 30)).toBe(2); + }); + + it("only discovers an extra page when totals are unavailable", () => { + expect(discoveredPageCount(1, false)).toBe(1); + expect(discoveredPageCount(1, true)).toBe(2); + expect(discoveredPageCount(2, true)).toBe(3); + }); + + it("keeps pager lifecycle in one state", async () => { + let release!: (page: { items: string[] }) => void; + const pager = createCursorPager( + { items: ["first"], cursor: "next" }, + () => new Promise<{ items: string[] }>((resolve) => (release = resolve)) + ); + + const loading = pager.select(2); + expect(pager.state).toEqual({ kind: "loading", page: 1 }); + release({ items: ["second"] }); + await loading; + expect(pager.state).toEqual({ kind: "at-page", page: 2 }); + + const failing = createCursorPager({ items: ["first"], cursor: "next" }, async () => { + throw new Error("nope"); + }); + await failing.select(2); + expect(failing.state).toEqual({ kind: "failed", page: 1 }); + }); +}); diff --git a/web/src/lib/components/profile/tabs/PeopleTab.svelte b/web/src/lib/components/profile/tabs/PeopleTab.svelte index 2ce617c8..37c1d7aa 100644 --- a/web/src/lib/components/profile/tabs/PeopleTab.svelte +++ b/web/src/lib/components/profile/tabs/PeopleTab.svelte @@ -3,63 +3,75 @@ import { getAuth } from "$lib/auth.svelte"; import { createBobbinClient } from "$lib/api/client"; import { IdentityCache } from "$lib/api/identity"; - import { fetchPeoplePage } from "../pages"; import FollowCard from "../FollowCard.svelte"; import Section from "$lib/components/ui/Section.svelte"; - import LoadMore from "$lib/components/ui/LoadMore.svelte"; + import Pagination from "$lib/components/ui/Pagination.svelte"; + import Error from "$lib/components/ui/Error.svelte"; + import { createCursorPager, pageCount } from "../pagination.svelte"; + import { PROFILE_PAGE_LIMIT, fetchPeoplePage } from "../pages"; import type { PersonData } from "../types"; interface Props { initial: PersonData[]; cursor?: string; + total: number; did: string; direction: "followers" | "following"; title: string; emptyMessage: string; } - let { initial, cursor: initialCursor, did, direction, title, emptyMessage }: Props = $props(); + let { + initial, + cursor: initialCursor, + total, + did, + direction, + title, + emptyMessage + }: Props = $props(); const auth = getAuth(); - let extra = $state([]); - let cursor = $state(untrack(() => initialCursor)); - let loading = $state(false); - let failed = $state(false); // shared across pages so repeat dids only resolve once let identityCache: IdentityCache | undefined; - - const people = $derived([...initial, ...extra]); - - const loadMore = async () => { - if (loading || !cursor) return; - loading = true; - failed = false; - try { + const pager = createCursorPager( + { items: untrack(() => initial), cursor: untrack(() => initialCursor) }, + (cursor) => { const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); identityCache ??= new IdentityCache(ctx); - const next = await fetchPeoplePage(ctx, { + return fetchPeoplePage(ctx, { did, viewerDid: auth.currentDid ?? undefined, direction, cursor, cache: identityCache }); - extra = [...extra, ...next.items]; - cursor = next.cursor; - } catch { - failed = true; - } finally { - loading = false; } - }; + ); + const people = $derived(pager.items); + const pages = $derived(pageCount(total, PROFILE_PAGE_LIMIT));
{#each people as person (person.did)} {/each} - {#if cursor} - - {/if} + {#snippet footer()} + {#if pages > 1} +
+ +
+ {/if} + {#if pager.state.kind === "failed"} + + {/if} + {/snippet}
diff --git a/web/src/lib/components/profile/tabs/RepoListTab.stories.svelte b/web/src/lib/components/profile/tabs/RepoListTab.stories.svelte new file mode 100644 index 00000000..bfae5402 --- /dev/null +++ b/web/src/lib/components/profile/tabs/RepoListTab.stories.svelte @@ -0,0 +1,49 @@ + + + + {#snippet template(args)} + + + + {/snippet} + diff --git a/web/src/lib/components/profile/tabs/RepoListTab.svelte b/web/src/lib/components/profile/tabs/RepoListTab.svelte index ceebf3b9..f723bbfd 100644 --- a/web/src/lib/components/profile/tabs/RepoListTab.svelte +++ b/web/src/lib/components/profile/tabs/RepoListTab.svelte @@ -4,10 +4,12 @@ import { resolve } from "$app/paths"; import { getAuth } from "$lib/auth.svelte"; import { createBobbinClient } from "$lib/api/client"; - import { fetchReposPage } from "../pages"; import RepoCard from "$lib/components/repo/RepoCard.svelte"; import Section from "$lib/components/ui/Section.svelte"; - import LoadMore from "$lib/components/ui/LoadMore.svelte"; + import Pagination from "$lib/components/ui/Pagination.svelte"; + import Error from "$lib/components/ui/Error.svelte"; + import { createCursorPager, discoveredPageCount, pageCount } from "../pagination.svelte"; + import { PROFILE_PAGE_LIMIT, fetchReposPage } from "../pages"; import { repoKey, type RepoCardData } from "../types"; import Search from "$icon/search"; import X from "$icon/x"; @@ -15,43 +17,35 @@ interface Props { initial: RepoCardData[]; cursor?: string; + total: number; did: string; handle: string; } - let { initial, cursor: initialCursor, did, handle }: Props = $props(); + let { initial, cursor: initialCursor, total, did, handle }: Props = $props(); const auth = getAuth(); - let extra = $state([]); - let cursor = $state(untrack(() => initialCursor)); - let loading = $state(false); - let failed = $state(false); - - const repos = $derived([...initial, ...extra]); let searchQuery = $derived(page.url.searchParams.get("q") ?? ""); - - const loadMore = async () => { - if (loading || !cursor) return; - loading = true; - failed = false; - try { + const pager = createCursorPager( + { items: untrack(() => initial), cursor: untrack(() => initialCursor) }, + (cursor) => { const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); - const next = await fetchReposPage(ctx, { + return fetchReposPage(ctx, { did, handle, viewerDid: auth.currentDid ?? undefined, q: searchQuery || undefined, cursor }); - extra = [...extra, ...next.items]; - cursor = next.cursor; - } catch { - failed = true; - } finally { - loading = false; } - }; + ); + const repos = $derived(pager.items); + const pages = $derived( + searchQuery + ? discoveredPageCount(pager.state.page, pager.hasNext) + : pageCount(total, PROFILE_PAGE_LIMIT) + );
{/each} - {#if cursor} - - {/if} + {#snippet footer()} + {#if pages > 1} +
+ +
+ {/if} + {#if pager.state.kind === "failed"} + + {/if} + {/snippet}
diff --git a/web/src/lib/components/profile/tabs/StarredTab.svelte b/web/src/lib/components/profile/tabs/StarredTab.svelte index d5096904..f110bafa 100644 --- a/web/src/lib/components/profile/tabs/StarredTab.svelte +++ b/web/src/lib/components/profile/tabs/StarredTab.svelte @@ -4,53 +4,43 @@ import { getAuth } from "$lib/auth.svelte"; import { createBobbinClient } from "$lib/api/client"; import { IdentityCache } from "$lib/api/identity"; - import { fetchStarredPage } from "../pages"; import RepoCard from "$lib/components/repo/RepoCard.svelte"; import Card from "$lib/components/ui/Card.svelte"; import Section from "$lib/components/ui/Section.svelte"; - import LoadMore from "$lib/components/ui/LoadMore.svelte"; + import Pagination from "$lib/components/ui/Pagination.svelte"; + import Error from "$lib/components/ui/Error.svelte"; + import { createCursorPager, pageCount } from "../pagination.svelte"; + import { PROFILE_PAGE_LIMIT, fetchStarredPage } from "../pages"; import type { StarData } from "../types"; interface Props { initial: StarData[]; cursor?: string; + total: number; did: string; } - let { initial, cursor: initialCursor, did }: Props = $props(); + let { initial, cursor: initialCursor, total, did }: Props = $props(); const auth = getAuth(); - let extra = $state([]); - let cursor = $state(untrack(() => initialCursor)); - let loading = $state(false); - let failed = $state(false); // shared across pages so repeat repo owners only resolve once let identityCache: IdentityCache | undefined; - - const stars = $derived([...initial, ...extra]); - - const loadMore = async () => { - if (loading || !cursor) return; - loading = true; - failed = false; - try { + const pager = createCursorPager( + { items: untrack(() => initial), cursor: untrack(() => initialCursor) }, + (cursor) => { const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); identityCache ??= new IdentityCache(ctx); - const next = await fetchStarredPage(ctx, { + return fetchStarredPage(ctx, { did, viewerDid: auth.currentDid ?? undefined, cursor, cache: identityCache }); - extra = [...extra, ...next.items]; - cursor = next.cursor; - } catch { - failed = true; - } finally { - loading = false; } - }; + ); + const stars = $derived(pager.items); + const pages = $derived(pageCount(total, PROFILE_PAGE_LIMIT));
@@ -68,7 +58,21 @@ {/if} {/each} - {#if cursor} - - {/if} + {#snippet footer()} + {#if pages > 1} +
+ +
+ {/if} + {#if pager.state.kind === "failed"} + + {/if} + {/snippet}
diff --git a/web/src/lib/components/profile/tabs/StringListTab.svelte b/web/src/lib/components/profile/tabs/StringListTab.svelte index 62fc458e..fb9059f0 100644 --- a/web/src/lib/components/profile/tabs/StringListTab.svelte +++ b/web/src/lib/components/profile/tabs/StringListTab.svelte @@ -2,52 +2,56 @@ import { untrack } from "svelte"; import { getAuth } from "$lib/auth.svelte"; import { createBobbinClient } from "$lib/api/client"; - import { fetchStringsPage } from "../pages"; import StringCard from "../StringCard.svelte"; import Section from "$lib/components/ui/Section.svelte"; - import LoadMore from "$lib/components/ui/LoadMore.svelte"; + import Pagination from "$lib/components/ui/Pagination.svelte"; + import Error from "$lib/components/ui/Error.svelte"; + import { createCursorPager, pageCount } from "../pagination.svelte"; + import { PROFILE_PAGE_LIMIT, fetchStringsPage } from "../pages"; import type { StringCardData } from "../types"; interface Props { initial: StringCardData[]; cursor?: string; + total: number; did: string; handle: string; } - let { initial, cursor: initialCursor, did, handle }: Props = $props(); + let { initial, cursor: initialCursor, total, did, handle }: Props = $props(); const auth = getAuth(); - let extra = $state([]); - let cursor = $state(untrack(() => initialCursor)); - let loading = $state(false); - let failed = $state(false); - - const strings = $derived([...initial, ...extra]); - - const loadMore = async () => { - if (loading || !cursor) return; - loading = true; - failed = false; - try { + const pager = createCursorPager( + { items: untrack(() => initial), cursor: untrack(() => initialCursor) }, + (cursor) => { const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); - const next = await fetchStringsPage(ctx, { did, handle, cursor }); - extra = [...extra, ...next.items]; - cursor = next.cursor; - } catch { - failed = true; - } finally { - loading = false; + return fetchStringsPage(ctx, { did, handle, cursor }); } - }; + ); + const strings = $derived(pager.items); + const pages = $derived(pageCount(total, PROFILE_PAGE_LIMIT));
{#each strings as entry (entry.rkey)} {/each} - {#if cursor} - - {/if} + {#snippet footer()} + {#if pages > 1} +
+ +
+ {/if} + {#if pager.state.kind === "failed"} + + {/if} + {/snippet}
diff --git a/web/src/lib/components/profile/tabs/VouchTab.svelte b/web/src/lib/components/profile/tabs/VouchTab.svelte index edf62d18..c8a8d42a 100644 --- a/web/src/lib/components/profile/tabs/VouchTab.svelte +++ b/web/src/lib/components/profile/tabs/VouchTab.svelte @@ -3,56 +3,45 @@ import { getAuth } from "$lib/auth.svelte"; import { createBobbinClient } from "$lib/api/client"; import { IdentityCache } from "$lib/api/identity"; - import { fetchVouchesPage, type VouchCursors } from "../pages"; import VouchCard from "../VouchCard.svelte"; import Section from "$lib/components/ui/Section.svelte"; - import LoadMore from "$lib/components/ui/LoadMore.svelte"; + import Pagination from "$lib/components/ui/Pagination.svelte"; + import Error from "$lib/components/ui/Error.svelte"; + import { createCursorPager, pageCount } from "../pagination.svelte"; + import { PROFILE_PAGE_LIMIT, fetchVouchesPage, type VouchCursors } from "../pages"; import type { VouchData } from "../types"; interface Props { initial: VouchData[]; cursors: VouchCursors; + total: number; did: string; isSelf: boolean; profileHandle: string; } - let { initial, cursors: initialCursors, did, isSelf, profileHandle }: Props = $props(); + let { initial, cursors: initialCursors, total, did, isSelf, profileHandle }: Props = $props(); const auth = getAuth(); - let extra = $state([]); - let cursors = $state(untrack(() => initialCursors)); - let loading = $state(false); - let failed = $state(false); // shared across pages so repeat dids only resolve once let identityCache: IdentityCache | undefined; const profileLabel = $derived(isSelf ? "you" : profileHandle); - // incoming and outgoing pages interleave, so the merged list re-sorts on append - const vouches = $derived( - [...initial, ...extra].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ) - ); - const exhausted = $derived(cursors.incoming === null && cursors.outgoing === null); - - const loadMore = async () => { - if (loading || exhausted) return; - loading = true; - failed = false; - try { + const pager = createCursorPager( + { items: untrack(() => initial), cursor: untrack(() => initialCursors) }, + (cursors) => { const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); identityCache ??= new IdentityCache(ctx); - const next = await fetchVouchesPage(ctx, { did, cursors, cache: identityCache }); - extra = [...extra, ...next.items]; - cursors = next.cursors; - } catch { - failed = true; - } finally { - loading = false; - } - }; + return fetchVouchesPage(ctx, { did, cursors, cache: identityCache }).then((next) => ({ + items: next.items, + cursor: next.cursors + })); + }, + (cursors) => Boolean(cursors?.incoming ?? cursors?.outgoing) + ); + const vouches = $derived(pager.items); + const pages = $derived(pageCount(total, PROFILE_PAGE_LIMIT));
{/each} - {#if !exhausted} - - {/if} + {#snippet footer()} + {#if pages > 1} +
+ +
+ {/if} + {#if pager.state.kind === "failed"} + + {/if} + {/snippet}
diff --git a/web/src/lib/components/repo/CommitLogView.stories.svelte b/web/src/lib/components/repo/CommitLogView.stories.svelte index 2f07c0d0..8f4a4e27 100644 --- a/web/src/lib/components/repo/CommitLogView.stories.svelte +++ b/web/src/lib/components/repo/CommitLogView.stories.svelte @@ -1,6 +1,6 @@ diff --git a/web/src/lib/components/repo/CommitLogView.svelte b/web/src/lib/components/repo/CommitLogView.svelte index 5c064ae2..d07df54f 100644 --- a/web/src/lib/components/repo/CommitLogView.svelte +++ b/web/src/lib/components/repo/CommitLogView.svelte @@ -1,13 +1,11 @@ - - - - diff --git a/web/src/lib/components/ui/LoadMore.svelte b/web/src/lib/components/ui/LoadMore.svelte deleted file mode 100644 index 31f5bbd3..00000000 --- a/web/src/lib/components/ui/LoadMore.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - -
- -
diff --git a/web/src/lib/components/ui/Pagination.stories.svelte b/web/src/lib/components/ui/Pagination.stories.svelte index dd5efa29..82310faf 100644 --- a/web/src/lib/components/ui/Pagination.stories.svelte +++ b/web/src/lib/components/ui/Pagination.stories.svelte @@ -9,7 +9,8 @@ argTypes: { page: { control: { type: "number" } }, total: { control: { type: "number" } }, - labels: { control: { type: "boolean" } } + labels: { control: { type: "boolean" } }, + disabled: { control: { type: "boolean" } } }, args: { page: 1, @@ -25,3 +26,4 @@ + diff --git a/web/src/lib/components/ui/Pagination.svelte b/web/src/lib/components/ui/Pagination.svelte index ef3d5d4b..a9771bef 100644 --- a/web/src/lib/components/ui/Pagination.svelte +++ b/web/src/lib/components/ui/Pagination.svelte @@ -59,14 +59,23 @@ onchange?: (page: number) => void; class?: string; labels?: boolean; + disabled?: boolean; } - let { page = $bindable(1), total, onchange, class: className, labels }: Props = $props(); + let { + page = $bindable(1), + total, + onchange, + class: className, + labels, + disabled = false + }: Props = $props(); const count = $derived(Math.max(total, 1)); const items = $derived(getPageItems(Math.min(Math.max(page, 1), count), count)); function goTo(target: number) { + if (disabled) return; const next = Math.min(Math.max(target, 1), count); if (next === page) return; page = next; @@ -74,11 +83,15 @@ } -