This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

web: use pagination component properly everywhere, fmt

Signed-off-by: dawn <dawn@tangled.org>

author
dawn
date (Aug 1, 2026, 11:40 AM +0300) commit 733fe419 parent 4dfa9fab change-id mqqwqrou
+432 -241
+1 -1
web/src/lib/components/profile/pages.ts
··· 1 1 // page fetchers for the profile tabs, shared between the route load (first 2 - // page) and the tab components (load more). identities come from the enrich 2 + // page) and the tab components (pagination). identities come from the enrich 3 3 // sidecar's minidoc payloads, resolveMiniDoc only fires for sidecar misses 4 4 5 5 import type { BobbinContext } from "$lib/api/client";
+59
web/src/lib/components/profile/pagination.svelte.ts
··· 1 + export interface CursorPage<T, C> { 2 + items: T[]; 3 + cursor?: C; 4 + } 5 + 6 + type CursorPagerState = 7 + | { kind: "at-page"; page: number } 8 + | { kind: "loading"; page: number } 9 + | { kind: "failed"; page: number }; 10 + 11 + export const pageCount = (total: number, limit: number): number => 12 + Math.max(1, Math.ceil(Math.max(total, 0) / limit)); 13 + 14 + // search uses this since there isn't a known limit 15 + export const discoveredPageCount = (page: number, hasNext: boolean): number => 16 + Math.max(1, page + (hasNext ? 1 : 0)); 17 + 18 + export const createCursorPager = <T, C>( 19 + initial: CursorPage<T, C>, 20 + load: (cursor: C) => Promise<CursorPage<T, C>>, 21 + hasNext: (cursor: C | undefined) => boolean = (cursor) => cursor !== undefined 22 + ) => { 23 + let state = $state<CursorPagerState>({ kind: "at-page", page: 1 }); 24 + let pages = $state<Record<number, CursorPage<T, C>>>({ 1: initial }); 25 + 26 + const select = async (target: number) => { 27 + if (state.kind === "loading" || target < 1 || target === state.page) return; 28 + 29 + const currentPage = state.page; 30 + state = { kind: "loading", page: currentPage }; 31 + try { 32 + const loaded = { ...pages }; 33 + for (let next = 2; next <= target; next++) { 34 + if (loaded[next]) continue; 35 + const cursor = loaded[next - 1]?.cursor; 36 + if (!hasNext(cursor)) break; 37 + loaded[next] = await load(cursor as C); 38 + } 39 + 40 + pages = loaded; 41 + state = { kind: "at-page", page: loaded[target] ? target : currentPage }; 42 + } catch { 43 + state = { kind: "failed", page: currentPage }; 44 + } 45 + }; 46 + 47 + return { 48 + get state() { 49 + return state; 50 + }, 51 + get items() { 52 + return pages[state.page]?.items ?? []; 53 + }, 54 + get hasNext() { 55 + return hasNext(pages[state.page]?.cursor); 56 + }, 57 + select 58 + }; 59 + };
+36
web/src/lib/components/profile/pagination.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { createCursorPager, discoveredPageCount, pageCount } from "./pagination.svelte"; 3 + 4 + describe("profile pagination counts", () => { 5 + it("uses the known total for profile lists", () => { 6 + expect(pageCount(4, 30)).toBe(1); 7 + expect(pageCount(30, 30)).toBe(1); 8 + expect(pageCount(31, 30)).toBe(2); 9 + }); 10 + 11 + it("only discovers an extra page when totals are unavailable", () => { 12 + expect(discoveredPageCount(1, false)).toBe(1); 13 + expect(discoveredPageCount(1, true)).toBe(2); 14 + expect(discoveredPageCount(2, true)).toBe(3); 15 + }); 16 + 17 + it("keeps pager lifecycle in one state", async () => { 18 + let release!: (page: { items: string[] }) => void; 19 + const pager = createCursorPager( 20 + { items: ["first"], cursor: "next" }, 21 + () => new Promise<{ items: string[] }>((resolve) => (release = resolve)) 22 + ); 23 + 24 + const loading = pager.select(2); 25 + expect(pager.state).toEqual({ kind: "loading", page: 1 }); 26 + release({ items: ["second"] }); 27 + await loading; 28 + expect(pager.state).toEqual({ kind: "at-page", page: 2 }); 29 + 30 + const failing = createCursorPager({ items: ["first"], cursor: "next" }, async () => { 31 + throw new Error("nope"); 32 + }); 33 + await failing.select(2); 34 + expect(failing.state).toEqual({ kind: "failed", page: 1 }); 35 + }); 36 + });
+38 -26
web/src/lib/components/profile/tabs/PeopleTab.svelte
··· 3 3 import { getAuth } from "$lib/auth.svelte"; 4 4 import { createBobbinClient } from "$lib/api/client"; 5 5 import { IdentityCache } from "$lib/api/identity"; 6 - import { fetchPeoplePage } from "../pages"; 7 6 import FollowCard from "../FollowCard.svelte"; 8 7 import Section from "$lib/components/ui/Section.svelte"; 9 - import LoadMore from "$lib/components/ui/LoadMore.svelte"; 8 + import Pagination from "$lib/components/ui/Pagination.svelte"; 9 + import Error from "$lib/components/ui/Error.svelte"; 10 + import { createCursorPager, pageCount } from "../pagination.svelte"; 11 + import { PROFILE_PAGE_LIMIT, fetchPeoplePage } from "../pages"; 10 12 import type { PersonData } from "../types"; 11 13 12 14 interface Props { 13 15 initial: PersonData[]; 14 16 cursor?: string; 17 + total: number; 15 18 did: string; 16 19 direction: "followers" | "following"; 17 20 title: string; 18 21 emptyMessage: string; 19 22 } 20 23 21 - let { initial, cursor: initialCursor, did, direction, title, emptyMessage }: Props = $props(); 24 + let { 25 + initial, 26 + cursor: initialCursor, 27 + total, 28 + did, 29 + direction, 30 + title, 31 + emptyMessage 32 + }: Props = $props(); 22 33 23 34 const auth = getAuth(); 24 35 25 - let extra = $state<PersonData[]>([]); 26 - let cursor = $state(untrack(() => initialCursor)); 27 - let loading = $state(false); 28 - let failed = $state(false); 29 36 // shared across pages so repeat dids only resolve once 30 37 let identityCache: IdentityCache | undefined; 31 - 32 - const people = $derived([...initial, ...extra]); 33 - 34 - const loadMore = async () => { 35 - if (loading || !cursor) return; 36 - loading = true; 37 - failed = false; 38 - try { 38 + const pager = createCursorPager( 39 + { items: untrack(() => initial), cursor: untrack(() => initialCursor) }, 40 + (cursor) => { 39 41 const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); 40 42 identityCache ??= new IdentityCache(ctx); 41 - const next = await fetchPeoplePage(ctx, { 43 + return fetchPeoplePage(ctx, { 42 44 did, 43 45 viewerDid: auth.currentDid ?? undefined, 44 46 direction, 45 47 cursor, 46 48 cache: identityCache 47 49 }); 48 - extra = [...extra, ...next.items]; 49 - cursor = next.cursor; 50 - } catch { 51 - failed = true; 52 - } finally { 53 - loading = false; 54 50 } 55 - }; 51 + ); 52 + const people = $derived(pager.items); 53 + const pages = $derived(pageCount(total, PROFILE_PAGE_LIMIT)); 56 54 </script> 57 55 58 56 <Section {title} empty={people.length === 0} {emptyMessage} listClass="flex flex-col gap-8"> 59 57 {#each people as person (person.did)} 60 58 <FollowCard {person} /> 61 59 {/each} 62 - {#if cursor} 63 - <LoadMore {loading} {failed} onclick={loadMore} /> 64 - {/if} 60 + {#snippet footer()} 61 + {#if pages > 1} 62 + <div class="mt-4 flex justify-center"> 63 + <Pagination 64 + page={pager.state.page} 65 + total={pages} 66 + labels 67 + disabled={pager.state.kind === "loading"} 68 + onchange={pager.select} 69 + class="gap-5" 70 + /> 71 + </div> 72 + {/if} 73 + {#if pager.state.kind === "failed"} 74 + <Error label="couldn't load that page. try again." class="mt-2" /> 75 + {/if} 76 + {/snippet} 65 77 </Section>
+49
web/src/lib/components/profile/tabs/RepoListTab.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta, type StoryContext } from "@storybook/addon-svelte-csf"; 3 + import { expect } from "storybook/test"; 4 + import MockAuthProvider from "$lib/components/testing/MockAuthProvider.svelte"; 5 + import type { RepoCardData } from "../types"; 6 + import RepoListTab from "./RepoListTab.svelte"; 7 + 8 + const repo = (name: string, index: number): RepoCardData => ({ 9 + name, 10 + repoDid: `did:plc:repo${index}`, 11 + ownerHandle: "dawn.tngl.boltless.dev", 12 + createdAt: "2026-08-01T00:00:00Z" 13 + }); 14 + 15 + const repos = [ 16 + repo("mittens", 1), 17 + repo("goggles", 2), 18 + repo("agv5di3saewnn", 3), 19 + repo("wetsuit", 4) 20 + ]; 21 + type PlayContext = Pick<StoryContext<Record<string, unknown>>, "canvas">; 22 + 23 + const onePage = async ({ canvas }: PlayContext) => { 24 + for (const { name } of repos) { 25 + await expect(canvas.getByRole("link", { name })).toBeVisible(); 26 + } 27 + await expect(canvas.queryByRole("navigation", { name: "Pagination" })).toBeNull(); 28 + }; 29 + 30 + const { Story } = defineMeta({ 31 + title: "Profile/RepoListTab", 32 + component: RepoListTab, 33 + tags: ["autodocs"], 34 + args: { 35 + initial: repos, 36 + total: repos.length, 37 + did: "did:plc:dawn", 38 + handle: "dawn.tngl.boltless.dev" 39 + } 40 + }); 41 + </script> 42 + 43 + <Story name="Four repositories fit on one page" play={onePage}> 44 + {#snippet template(args)} 45 + <MockAuthProvider> 46 + <RepoListTab {...args} /> 47 + </MockAuthProvider> 48 + {/snippet} 49 + </Story>
+34 -26
web/src/lib/components/profile/tabs/RepoListTab.svelte
··· 4 4 import { resolve } from "$app/paths"; 5 5 import { getAuth } from "$lib/auth.svelte"; 6 6 import { createBobbinClient } from "$lib/api/client"; 7 - import { fetchReposPage } from "../pages"; 8 7 import RepoCard from "$lib/components/repo/RepoCard.svelte"; 9 8 import Section from "$lib/components/ui/Section.svelte"; 10 - import LoadMore from "$lib/components/ui/LoadMore.svelte"; 9 + import Pagination from "$lib/components/ui/Pagination.svelte"; 10 + import Error from "$lib/components/ui/Error.svelte"; 11 + import { createCursorPager, discoveredPageCount, pageCount } from "../pagination.svelte"; 12 + import { PROFILE_PAGE_LIMIT, fetchReposPage } from "../pages"; 11 13 import { repoKey, type RepoCardData } from "../types"; 12 14 import Search from "$icon/search"; 13 15 import X from "$icon/x"; ··· 15 17 interface Props { 16 18 initial: RepoCardData[]; 17 19 cursor?: string; 20 + total: number; 18 21 did: string; 19 22 handle: string; 20 23 } 21 24 22 - let { initial, cursor: initialCursor, did, handle }: Props = $props(); 25 + let { initial, cursor: initialCursor, total, did, handle }: Props = $props(); 23 26 24 27 const auth = getAuth(); 25 28 26 - let extra = $state<RepoCardData[]>([]); 27 - let cursor = $state(untrack(() => initialCursor)); 28 - let loading = $state(false); 29 - let failed = $state(false); 30 - 31 - const repos = $derived([...initial, ...extra]); 32 29 let searchQuery = $derived(page.url.searchParams.get("q") ?? ""); 33 - 34 - const loadMore = async () => { 35 - if (loading || !cursor) return; 36 - loading = true; 37 - failed = false; 38 - try { 30 + const pager = createCursorPager( 31 + { items: untrack(() => initial), cursor: untrack(() => initialCursor) }, 32 + (cursor) => { 39 33 const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); 40 - const next = await fetchReposPage(ctx, { 34 + return fetchReposPage(ctx, { 41 35 did, 42 36 handle, 43 37 viewerDid: auth.currentDid ?? undefined, 44 38 q: searchQuery || undefined, 45 39 cursor 46 40 }); 47 - extra = [...extra, ...next.items]; 48 - cursor = next.cursor; 49 - } catch { 50 - failed = true; 51 - } finally { 52 - loading = false; 53 41 } 54 - }; 42 + ); 43 + const repos = $derived(pager.items); 44 + const pages = $derived( 45 + searchQuery 46 + ? discoveredPageCount(pager.state.page, pager.hasNext) 47 + : pageCount(total, PROFILE_PAGE_LIMIT) 48 + ); 55 49 </script> 56 50 57 51 <Section ··· 89 83 {#each repos as repo (repoKey(repo))} 90 84 <RepoCard {repo} showOwner={false} /> 91 85 {/each} 92 - {#if cursor} 93 - <LoadMore {loading} {failed} onclick={loadMore} /> 94 - {/if} 86 + {#snippet footer()} 87 + {#if pages > 1} 88 + <div class="mt-4 flex justify-center"> 89 + <Pagination 90 + page={pager.state.page} 91 + total={pages} 92 + labels 93 + disabled={pager.state.kind === "loading"} 94 + onchange={pager.select} 95 + class="gap-5" 96 + /> 97 + </div> 98 + {/if} 99 + {#if pager.state.kind === "failed"} 100 + <Error label="couldn't load that page. try again." class="mt-2" /> 101 + {/if} 102 + {/snippet} 95 103 </Section>
+30 -26
web/src/lib/components/profile/tabs/StarredTab.svelte
··· 4 4 import { getAuth } from "$lib/auth.svelte"; 5 5 import { createBobbinClient } from "$lib/api/client"; 6 6 import { IdentityCache } from "$lib/api/identity"; 7 - import { fetchStarredPage } from "../pages"; 8 7 import RepoCard from "$lib/components/repo/RepoCard.svelte"; 9 8 import Card from "$lib/components/ui/Card.svelte"; 10 9 import Section from "$lib/components/ui/Section.svelte"; 11 - import LoadMore from "$lib/components/ui/LoadMore.svelte"; 10 + import Pagination from "$lib/components/ui/Pagination.svelte"; 11 + import Error from "$lib/components/ui/Error.svelte"; 12 + import { createCursorPager, pageCount } from "../pagination.svelte"; 13 + import { PROFILE_PAGE_LIMIT, fetchStarredPage } from "../pages"; 12 14 import type { StarData } from "../types"; 13 15 14 16 interface Props { 15 17 initial: StarData[]; 16 18 cursor?: string; 19 + total: number; 17 20 did: string; 18 21 } 19 22 20 - let { initial, cursor: initialCursor, did }: Props = $props(); 23 + let { initial, cursor: initialCursor, total, did }: Props = $props(); 21 24 22 25 const auth = getAuth(); 23 26 24 - let extra = $state<StarData[]>([]); 25 - let cursor = $state(untrack(() => initialCursor)); 26 - let loading = $state(false); 27 - let failed = $state(false); 28 27 // shared across pages so repeat repo owners only resolve once 29 28 let identityCache: IdentityCache | undefined; 30 - 31 - const stars = $derived([...initial, ...extra]); 32 - 33 - const loadMore = async () => { 34 - if (loading || !cursor) return; 35 - loading = true; 36 - failed = false; 37 - try { 29 + const pager = createCursorPager( 30 + { items: untrack(() => initial), cursor: untrack(() => initialCursor) }, 31 + (cursor) => { 38 32 const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); 39 33 identityCache ??= new IdentityCache(ctx); 40 - const next = await fetchStarredPage(ctx, { 34 + return fetchStarredPage(ctx, { 41 35 did, 42 36 viewerDid: auth.currentDid ?? undefined, 43 37 cursor, 44 38 cache: identityCache 45 39 }); 46 - extra = [...extra, ...next.items]; 47 - cursor = next.cursor; 48 - } catch { 49 - failed = true; 50 - } finally { 51 - loading = false; 52 40 } 53 - }; 41 + ); 42 + const stars = $derived(pager.items); 43 + const pages = $derived(pageCount(total, PROFILE_PAGE_LIMIT)); 54 44 </script> 55 45 56 46 <Section title="Starred" empty={stars.length === 0} emptyMessage="No stars yet."> ··· 68 58 </Card> 69 59 {/if} 70 60 {/each} 71 - {#if cursor} 72 - <LoadMore {loading} {failed} onclick={loadMore} /> 73 - {/if} 61 + {#snippet footer()} 62 + {#if pages > 1} 63 + <div class="mt-4 flex justify-center"> 64 + <Pagination 65 + page={pager.state.page} 66 + total={pages} 67 + labels 68 + disabled={pager.state.kind === "loading"} 69 + onchange={pager.select} 70 + class="gap-5" 71 + /> 72 + </div> 73 + {/if} 74 + {#if pager.state.kind === "failed"} 75 + <Error label="couldn't load that page. try again." class="mt-2" /> 76 + {/if} 77 + {/snippet} 74 78 </Section>
+30 -26
web/src/lib/components/profile/tabs/StringListTab.svelte
··· 2 2 import { untrack } from "svelte"; 3 3 import { getAuth } from "$lib/auth.svelte"; 4 4 import { createBobbinClient } from "$lib/api/client"; 5 - import { fetchStringsPage } from "../pages"; 6 5 import StringCard from "../StringCard.svelte"; 7 6 import Section from "$lib/components/ui/Section.svelte"; 8 - import LoadMore from "$lib/components/ui/LoadMore.svelte"; 7 + import Pagination from "$lib/components/ui/Pagination.svelte"; 8 + import Error from "$lib/components/ui/Error.svelte"; 9 + import { createCursorPager, pageCount } from "../pagination.svelte"; 10 + import { PROFILE_PAGE_LIMIT, fetchStringsPage } from "../pages"; 9 11 import type { StringCardData } from "../types"; 10 12 11 13 interface Props { 12 14 initial: StringCardData[]; 13 15 cursor?: string; 16 + total: number; 14 17 did: string; 15 18 handle: string; 16 19 } 17 20 18 - let { initial, cursor: initialCursor, did, handle }: Props = $props(); 21 + let { initial, cursor: initialCursor, total, did, handle }: Props = $props(); 19 22 20 23 const auth = getAuth(); 21 24 22 - let extra = $state<StringCardData[]>([]); 23 - let cursor = $state(untrack(() => initialCursor)); 24 - let loading = $state(false); 25 - let failed = $state(false); 26 - 27 - const strings = $derived([...initial, ...extra]); 28 - 29 - const loadMore = async () => { 30 - if (loading || !cursor) return; 31 - loading = true; 32 - failed = false; 33 - try { 25 + const pager = createCursorPager( 26 + { items: untrack(() => initial), cursor: untrack(() => initialCursor) }, 27 + (cursor) => { 34 28 const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); 35 - const next = await fetchStringsPage(ctx, { did, handle, cursor }); 36 - extra = [...extra, ...next.items]; 37 - cursor = next.cursor; 38 - } catch { 39 - failed = true; 40 - } finally { 41 - loading = false; 29 + return fetchStringsPage(ctx, { did, handle, cursor }); 42 30 } 43 - }; 31 + ); 32 + const strings = $derived(pager.items); 33 + const pages = $derived(pageCount(total, PROFILE_PAGE_LIMIT)); 44 34 </script> 45 35 46 36 <Section title="Strings" empty={strings.length === 0} emptyMessage="No strings yet."> 47 37 {#each strings as entry (entry.rkey)} 48 38 <StringCard {entry} /> 49 39 {/each} 50 - {#if cursor} 51 - <LoadMore {loading} {failed} onclick={loadMore} /> 52 - {/if} 40 + {#snippet footer()} 41 + {#if pages > 1} 42 + <div class="mt-4 flex justify-center"> 43 + <Pagination 44 + page={pager.state.page} 45 + total={pages} 46 + labels 47 + disabled={pager.state.kind === "loading"} 48 + onchange={pager.select} 49 + class="gap-5" 50 + /> 51 + </div> 52 + {/if} 53 + {#if pager.state.kind === "failed"} 54 + <Error label="couldn't load that page. try again." class="mt-2" /> 55 + {/if} 56 + {/snippet} 53 57 </Section>
+35 -32
web/src/lib/components/profile/tabs/VouchTab.svelte
··· 3 3 import { getAuth } from "$lib/auth.svelte"; 4 4 import { createBobbinClient } from "$lib/api/client"; 5 5 import { IdentityCache } from "$lib/api/identity"; 6 - import { fetchVouchesPage, type VouchCursors } from "../pages"; 7 6 import VouchCard from "../VouchCard.svelte"; 8 7 import Section from "$lib/components/ui/Section.svelte"; 9 - import LoadMore from "$lib/components/ui/LoadMore.svelte"; 8 + import Pagination from "$lib/components/ui/Pagination.svelte"; 9 + import Error from "$lib/components/ui/Error.svelte"; 10 + import { createCursorPager, pageCount } from "../pagination.svelte"; 11 + import { PROFILE_PAGE_LIMIT, fetchVouchesPage, type VouchCursors } from "../pages"; 10 12 import type { VouchData } from "../types"; 11 13 12 14 interface Props { 13 15 initial: VouchData[]; 14 16 cursors: VouchCursors; 17 + total: number; 15 18 did: string; 16 19 isSelf: boolean; 17 20 profileHandle: string; 18 21 } 19 22 20 - let { initial, cursors: initialCursors, did, isSelf, profileHandle }: Props = $props(); 23 + let { initial, cursors: initialCursors, total, did, isSelf, profileHandle }: Props = $props(); 21 24 22 25 const auth = getAuth(); 23 26 24 - let extra = $state<VouchData[]>([]); 25 - let cursors = $state(untrack(() => initialCursors)); 26 - let loading = $state(false); 27 - let failed = $state(false); 28 27 // shared across pages so repeat dids only resolve once 29 28 let identityCache: IdentityCache | undefined; 30 29 31 30 const profileLabel = $derived(isSelf ? "you" : profileHandle); 32 - // incoming and outgoing pages interleave, so the merged list re-sorts on append 33 - const vouches = $derived( 34 - [...initial, ...extra].sort( 35 - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 36 - ) 37 - ); 38 - const exhausted = $derived(cursors.incoming === null && cursors.outgoing === null); 39 - 40 - const loadMore = async () => { 41 - if (loading || exhausted) return; 42 - loading = true; 43 - failed = false; 44 - try { 31 + const pager = createCursorPager<VouchData, VouchCursors>( 32 + { items: untrack(() => initial), cursor: untrack(() => initialCursors) }, 33 + (cursors) => { 45 34 const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); 46 35 identityCache ??= new IdentityCache(ctx); 47 - const next = await fetchVouchesPage(ctx, { did, cursors, cache: identityCache }); 48 - extra = [...extra, ...next.items]; 49 - cursors = next.cursors; 50 - } catch { 51 - failed = true; 52 - } finally { 53 - loading = false; 54 - } 55 - }; 36 + return fetchVouchesPage(ctx, { did, cursors, cache: identityCache }).then((next) => ({ 37 + items: next.items, 38 + cursor: next.cursors 39 + })); 40 + }, 41 + (cursors) => Boolean(cursors?.incoming ?? cursors?.outgoing) 42 + ); 43 + const vouches = $derived(pager.items); 44 + const pages = $derived(pageCount(total, PROFILE_PAGE_LIMIT)); 56 45 </script> 57 46 58 47 <Section ··· 65 54 {#each vouches as vouch (vouch.uri)} 66 55 <VouchCard {vouch} {profileLabel} /> 67 56 {/each} 68 - {#if !exhausted} 69 - <LoadMore {loading} {failed} onclick={loadMore} /> 70 - {/if} 57 + {#snippet footer()} 58 + {#if pages > 1} 59 + <div class="mt-4 flex justify-center"> 60 + <Pagination 61 + page={pager.state.page} 62 + total={pages} 63 + labels 64 + disabled={pager.state.kind === "loading"} 65 + onchange={pager.select} 66 + class="gap-5" 67 + /> 68 + </div> 69 + {/if} 70 + {#if pager.state.kind === "failed"} 71 + <Error label="couldn't load that page. try again." class="mt-2" /> 72 + {/if} 73 + {/snippet} 71 74 </Section>
+20 -10
web/src/lib/components/repo/CommitLogView.stories.svelte
··· 1 1 <script module lang="ts"> 2 2 import { defineMeta, type StoryContext } from "@storybook/addon-svelte-csf"; 3 - import { expect, userEvent } from "storybook/test"; 3 + import { expect, fn, userEvent } from "storybook/test"; 4 4 import CommitLogView from "./CommitLogView.svelte"; 5 5 import type { CommitSummary } from "./types"; 6 6 ··· 14 14 when: "2026-07-28T09:00:00Z" 15 15 }); 16 16 const commits = Array.from({ length: 12 }, (_, index) => commit(index + 1)); 17 + const onPageChange = fn(); 17 18 type PlayContext = Pick<StoryContext<Record<string, unknown>>, "canvas">; 18 19 19 20 const firstPage = async ({ canvas }: PlayContext) => { 20 - await expect(canvas.queryByRole("link", { name: "Previous" })).toBeNull(); 21 - const next = canvas.getByRole("link", { name: "Next" }); 22 - await expect(next).toHaveAttribute("href", "/dawn/tangled/commits/main?page=2"); 21 + await expect(canvas.getByRole("button", { name: "Previous page" })).toBeDisabled(); 22 + const next = canvas.getByRole("button", { name: "Next page" }); 23 + await expect(next).toBeEnabled(); 24 + await expect(canvas.getByRole("button", { name: "Page 1" })).toHaveAttribute( 25 + "aria-current", 26 + "page" 27 + ); 28 + await userEvent.click(next); 29 + await expect(onPageChange).toHaveBeenCalledWith(2); 23 30 24 31 await expect(canvas.getAllByTitle("Copy SHA")).toHaveLength(commits.length); 25 32 await expect(canvas.getAllByTitle("Browse repository at this commit")).toHaveLength( ··· 33 40 }; 34 41 35 42 const middlePage = async ({ canvas }: PlayContext) => { 36 - const prev = canvas.getByRole("link", { name: "Previous" }); 37 - const next = canvas.getByRole("link", { name: "Next" }); 38 - await expect(prev).toHaveAttribute("href", "/dawn/tangled/commits/main?page=3"); 39 - await expect(next).toHaveAttribute("href", "/dawn/tangled/commits/main?page=5"); 43 + await expect(canvas.getByRole("button", { name: "Previous page" })).toBeEnabled(); 44 + await expect(canvas.getByRole("button", { name: "Next page" })).toBeEnabled(); 45 + await expect(canvas.getByRole("button", { name: "Page 4" })).toHaveAttribute( 46 + "aria-current", 47 + "page" 48 + ); 40 49 }; 41 50 42 51 const bodyExpand = async ({ canvas }: PlayContext) => { ··· 50 59 51 60 const emptyLog = async ({ canvas }: PlayContext) => { 52 61 await expect(canvas.getByText("No commits at main.")).toBeVisible(); 53 - await expect(canvas.queryByRole("link", { name: "Next" })).toBeNull(); 62 + await expect(canvas.queryByRole("navigation", { name: "Pagination" })).toBeNull(); 54 63 }; 55 64 56 65 const { Story } = defineMeta({ ··· 66 75 [commits[0].hash]: ["v1.0.0"] 67 76 }, 68 77 page: 1, 69 - pageCount: 5 78 + pageCount: 5, 79 + onPageChange 70 80 } 71 81 }); 72 82 </script>
+16 -19
web/src/lib/components/repo/CommitLogView.svelte
··· 1 1 <script lang="ts"> 2 2 import { resolve } from "$app/paths"; 3 - import ChevronLeft from "$icon/chevron-left"; 4 - import ChevronRight from "$icon/chevron-right"; 5 3 import Copy from "$icon/copy"; 6 4 import CopyCheck from "$icon/copy-check"; 7 5 import Ellipsis from "$icon/ellipsis"; 8 6 import FolderCode from "$icon/folder-code"; 9 7 import Avatar from "$lib/components/ui/Avatar.svelte"; 10 - import Button from "$lib/components/ui/Button.svelte"; 8 + import Pagination from "$lib/components/ui/Pagination.svelte"; 11 9 import Tag from "$lib/components/ui/Tag.svelte"; 12 10 import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; 13 11 import { createCopyFeedback } from "$lib/copy.svelte"; ··· 21 19 tagsByCommit?: Record<string, string[]>; 22 20 page: number; 23 21 pageCount: number; 22 + onPageChange?: (page: number) => void; 24 23 } 25 24 26 - let { ownerHandle, repoName, ref, commits, tagsByCommit = {}, page, pageCount }: Props = $props(); 25 + let { 26 + ownerHandle, 27 + repoName, 28 + ref, 29 + commits, 30 + tagsByCommit = {}, 31 + page, 32 + pageCount, 33 + onPageChange 34 + }: Props = $props(); 27 35 28 36 const base = $derived(`/${ownerHandle}/${repoName}`); 29 - const encodedRef = $derived(encodeURIComponent(ref)); 30 - 31 - const hasPrev = $derived(page > 1); 32 - const hasNext = $derived(page < pageCount); 33 - const pageHref = (next: number) => 34 - resolve(`${base}/commits/${encodedRef}${next > 1 ? `?page=${next}` : ""}` as "/"); 35 37 36 38 let expanded = $state<Record<string, boolean>>({}); 37 39 const copyFeedback = createCopyFeedback(); ··· 39 41 40 42 {#snippet authorCell(commit: CommitSummary)} 41 43 <span class="flex items-center gap-1"> 42 - <!-- no did/handle on a commit summary, always the placeholder --> 44 + <!-- no did/handle on a commit summary, always the placeholder --> 43 45 <Avatar size="size-6" /> 44 46 {#if commit.authorEmail} 45 47 <a href="mailto:{commit.authorEmail}" class="no-underline hover:underline"> ··· 206 208 {/if} 207 209 </section> 208 210 209 - {#if hasPrev || hasNext} 210 - <div class="mt-4 flex justify-end gap-2"> 211 - {#if hasPrev} 212 - <Button href={pageHref(page - 1)} icon={ChevronLeft} size="sm">Previous</Button> 213 - {/if} 214 - {#if hasNext} 215 - <Button href={pageHref(page + 1)} icon={ChevronRight} iconSide="right" size="sm">Next</Button> 216 - {/if} 211 + {#if pageCount > 1} 212 + <div class="mt-4 flex justify-center"> 213 + <Pagination {page} total={pageCount} labels onchange={onPageChange} class="gap-5" /> 217 214 </div> 218 215 {/if}
-21
web/src/lib/components/ui/LoadMore.stories.svelte
··· 1 - <script module lang="ts"> 2 - import { defineMeta } from "@storybook/addon-svelte-csf"; 3 - import LoadMore from "./LoadMore.svelte"; 4 - 5 - const { Story } = defineMeta({ 6 - title: "UI/LoadMore", 7 - component: LoadMore, 8 - tags: ["autodocs"], 9 - argTypes: { 10 - loading: { control: { type: "boolean" } }, 11 - failed: { control: { type: "boolean" } } 12 - }, 13 - args: { 14 - onclick: () => {} 15 - } 16 - }); 17 - </script> 18 - 19 - <Story name="Default" /> 20 - <Story name="Loading" args={{ loading: true }} /> 21 - <Story name="Failed" args={{ failed: true }} />
-18
web/src/lib/components/ui/LoadMore.svelte
··· 1 - <script lang="ts"> 2 - import Button from "./Button.svelte"; 3 - 4 - interface Props { 5 - loading?: boolean; 6 - failed?: boolean; 7 - onclick: () => void; 8 - class?: string; 9 - } 10 - 11 - let { loading = false, failed = false, onclick, class: className }: Props = $props(); 12 - </script> 13 - 14 - <div class="flex justify-center pt-4 {className ?? ''}"> 15 - <Button variant="ghost" {loading} {onclick}> 16 - {failed ? "couldn't load more, retry?" : "load more"} 17 - </Button> 18 - </div>
+3 -1
web/src/lib/components/ui/Pagination.stories.svelte
··· 9 9 argTypes: { 10 10 page: { control: { type: "number" } }, 11 11 total: { control: { type: "number" } }, 12 - labels: { control: { type: "boolean" } } 12 + labels: { control: { type: "boolean" } }, 13 + disabled: { control: { type: "boolean" } } 13 14 }, 14 15 args: { 15 16 page: 1, ··· 25 26 <Story name="SinglePage" args={{ page: 1, total: 1 }} /> 26 27 <Story name="ManyPages" args={{ page: 12, total: 40 }} /> 27 28 <Story name="WithLabels" args={{ page: 5, total: 10, labels: true }} /> 29 + <Story name="Disabled" args={{ page: 5, total: 10, disabled: true }} />
+18 -4
web/src/lib/components/ui/Pagination.svelte
··· 59 59 onchange?: (page: number) => void; 60 60 class?: string; 61 61 labels?: boolean; 62 + disabled?: boolean; 62 63 } 63 64 64 - let { page = $bindable(1), total, onchange, class: className, labels }: Props = $props(); 65 + let { 66 + page = $bindable(1), 67 + total, 68 + onchange, 69 + class: className, 70 + labels, 71 + disabled = false 72 + }: Props = $props(); 65 73 66 74 const count = $derived(Math.max(total, 1)); 67 75 const items = $derived(getPageItems(Math.min(Math.max(page, 1), count), count)); 68 76 69 77 function goTo(target: number) { 78 + if (disabled) return; 70 79 const next = Math.min(Math.max(target, 1), count); 71 80 if (next === page) return; 72 81 page = next; ··· 74 83 } 75 84 </script> 76 85 77 - <nav aria-label="Pagination" class={pagination({ class: className })}> 86 + <nav 87 + aria-label="Pagination" 88 + aria-busy={disabled || undefined} 89 + class={pagination({ class: className })} 90 + > 78 91 <Button 79 92 variant="ghost" 80 93 icon={ChevronLeft} 81 - disabled={page <= 1} 94 + disabled={disabled || page <= 1} 82 95 aria-label="Previous page" 83 96 onclick={() => goTo(page - 1)} 84 97 > ··· 89 102 {#if typeof item === "number"} 90 103 <Button 91 104 variant={item === page ? "default" : "ghost"} 105 + {disabled} 92 106 aria-current={item === page ? "page" : undefined} 93 107 aria-label={`Page ${item}`} 94 108 onclick={() => goTo(item)} ··· 109 123 variant="ghost" 110 124 icon={ChevronRight} 111 125 iconSide="right" 112 - disabled={page >= count} 126 + disabled={disabled || page >= count} 113 127 aria-label="Next page" 114 128 onclick={() => goTo(page + 1)} 115 129 >
+9
web/src/lib/components/ui/Section.stories.svelte
··· 48 48 {@render items()} 49 49 </Section> 50 50 </Story> 51 + 52 + <Story name="Footer" asChild> 53 + <Section title="Repositories" empty={false} emptyMessage="Nothing here."> 54 + {@render items()} 55 + {#snippet footer()} 56 + <div class="mt-4 flex justify-center text-sm text-foreground-subtle">footer</div> 57 + {/snippet} 58 + </Section> 59 + </Story>
+3
web/src/lib/components/ui/Section.svelte
··· 8 8 emptyMessage: string; 9 9 listClass?: string; 10 10 header?: Snippet; 11 + footer?: Snippet; 11 12 children: Snippet; 12 13 } 13 14 ··· 17 18 emptyMessage, 18 19 listClass = "flex flex-col gap-4", 19 20 header, 21 + footer, 20 22 children 21 23 }: Props = $props(); 22 24 </script> ··· 32 34 <div class={listClass}> 33 35 {@render children()} 34 36 </div> 37 + {@render footer?.()} 35 38 {/if} 36 39 </section>
+11 -1
web/src/routes/[handle]/+page.svelte
··· 16 16 <RepoListTab 17 17 initial={data.repos} 18 18 cursor={data.cursor} 19 + total={data.counts.repos} 19 20 did={data.identity.did} 20 21 handle={data.identity.handle} 21 22 /> 22 23 {/key} 23 24 {:else if data.tab === "starred"} 24 25 {#key data.stars} 25 - <StarredTab initial={data.stars} cursor={data.cursor} did={data.identity.did} /> 26 + <StarredTab 27 + initial={data.stars} 28 + cursor={data.cursor} 29 + total={data.counts.stars} 30 + did={data.identity.did} 31 + /> 26 32 {/key} 27 33 {:else if data.tab === "strings"} 28 34 {#key data.strings} 29 35 <StringListTab 30 36 initial={data.strings} 31 37 cursor={data.cursor} 38 + total={data.counts.strings} 32 39 did={data.identity.did} 33 40 handle={data.identity.handle} 34 41 /> ··· 38 45 <PeopleTab 39 46 initial={data.people} 40 47 cursor={data.cursor} 48 + total={data.counts.followers} 41 49 did={data.identity.did} 42 50 direction="followers" 43 51 title="Followers" ··· 49 57 <PeopleTab 50 58 initial={data.people} 51 59 cursor={data.cursor} 60 + total={data.counts.following} 52 61 did={data.identity.did} 53 62 direction="following" 54 63 title="Following" ··· 60 69 <VouchTab 61 70 initial={data.vouches} 62 71 cursors={data.cursors} 72 + total={data.vouchTotal} 63 73 did={data.identity.did} 64 74 isSelf={data.isSelf} 65 75 profileHandle={data.profileHandle}
+3
web/src/routes/[handle]/+page.ts
··· 1 1 import type { Did } from "@atcute/lexicons/syntax"; 2 2 import { createBobbinClient } from "$lib/api/client"; 3 + import { count } from "$lib/api/count"; 3 4 import { toHttpError } from "$lib/api/load"; 4 5 import { 5 6 fetchReposPage, ··· 54 55 } 55 56 case "vouches": { 56 57 const page = await fetchVouchesPage(ctx, { did }); 58 + const outgoing = await count(ctx, "sh.tangled.graph.countVouchesBy", did); 57 59 return { 58 60 tab: "vouches" as const, 59 61 vouches: page.items, 60 62 cursors: page.cursors, 63 + vouchTotal: parent.counts.vouches + outgoing.count, 61 64 isSelf: viewerDid === did, 62 65 profileHandle: handle 63 66 };
+14 -15
web/src/routes/[handle]/[repo]/branches/+page.svelte
··· 1 1 <script lang="ts"> 2 + import { goto } from "$app/navigation"; 2 3 import { resolve } from "$app/paths"; 3 - import ChevronLeft from "$icon/chevron-left"; 4 - import ChevronRight from "$icon/chevron-right"; 5 4 import BranchTable from "$lib/components/repo/BranchTable.svelte"; 6 - import Button from "$lib/components/ui/Button.svelte"; 5 + import Pagination from "$lib/components/ui/Pagination.svelte"; 7 6 8 7 let { data } = $props(); 9 8 10 - const hasPrev = $derived(data.page > 1); 11 - const hasNext = $derived(data.page < data.pageCount); 12 - const pageHref = (next: number) => 9 + const pageHref = (page: number) => 13 10 resolve( 14 - `/${data.repo.ownerHandle}/${data.repo.name}/branches${next > 1 ? `?page=${next}` : ""}` as "/" 11 + `/${data.repo.ownerHandle}/${data.repo.name}/branches${page > 1 ? `?page=${page}` : ""}` as "/" 15 12 ); 13 + const changePage = (page: number) => void goto(pageHref(page)); 16 14 </script> 17 15 18 16 <BranchTable ··· 21 19 branches={data.branches} 22 20 /> 23 21 24 - {#if hasPrev || hasNext} 25 - <div class="mt-4 flex justify-end gap-2"> 26 - {#if hasPrev} 27 - <Button href={pageHref(data.page - 1)} icon={ChevronLeft} size="sm">Previous</Button> 28 - {/if} 29 - {#if hasNext} 30 - <Button href={pageHref(data.page + 1)} icon={ChevronRight} iconSide="right" size="sm">Next</Button> 31 - {/if} 22 + {#if data.pageCount > 1} 23 + <div class="mt-4 flex justify-center"> 24 + <Pagination 25 + page={data.page} 26 + total={data.pageCount} 27 + labels 28 + onchange={changePage} 29 + class="gap-5" 30 + /> 32 31 </div> 33 32 {/if}
+9
web/src/routes/[handle]/[repo]/commits/[ref]/+page.svelte
··· 1 1 <script lang="ts"> 2 + import { goto } from "$app/navigation"; 3 + import { resolve } from "$app/paths"; 2 4 import CommitLogView from "$lib/components/repo/CommitLogView.svelte"; 3 5 4 6 let { data } = $props(); 7 + 8 + const pageHref = (page: number) => 9 + resolve( 10 + `/${data.repo.ownerHandle}/${data.repo.name}/commits/${encodeURIComponent(data.ref)}${page > 1 ? `?page=${page}` : ""}` as "/" 11 + ); 12 + const changePage = (page: number) => void goto(pageHref(page)); 5 13 </script> 6 14 7 15 <CommitLogView ··· 12 20 tagsByCommit={data.tagsByCommit} 13 21 page={data.page} 14 22 pageCount={data.pageCount} 23 + onPageChange={changePage} 15 24 />
+14 -15
web/src/routes/[handle]/[repo]/tags/+page.svelte
··· 1 1 <script lang="ts"> 2 + import { goto } from "$app/navigation"; 2 3 import { resolve } from "$app/paths"; 3 - import ChevronLeft from "$icon/chevron-left"; 4 - import ChevronRight from "$icon/chevron-right"; 5 4 import TagCard from "$lib/components/repo/TagCard.svelte"; 6 - import Button from "$lib/components/ui/Button.svelte"; 5 + import Pagination from "$lib/components/ui/Pagination.svelte"; 7 6 import TabPanel from "$lib/components/ui/TabPanel.svelte"; 8 7 9 8 let { data } = $props(); 10 9 11 - const hasPrev = $derived(data.page > 1); 12 - const hasNext = $derived(data.page < data.pageCount); 13 - const pageHref = (next: number) => 10 + const pageHref = (page: number) => 14 11 resolve( 15 - `/${data.repo.ownerHandle}/${data.repo.name}/tags${next > 1 ? `?page=${next}` : ""}` as "/" 12 + `/${data.repo.ownerHandle}/${data.repo.name}/tags${page > 1 ? `?page=${page}` : ""}` as "/" 16 13 ); 14 + const changePage = (page: number) => void goto(pageHref(page)); 17 15 </script> 18 16 19 17 <TabPanel> ··· 29 27 </div> 30 28 </TabPanel> 31 29 32 - {#if hasPrev || hasNext} 33 - <div class="mt-4 flex justify-end gap-2"> 34 - {#if hasPrev} 35 - <Button href={pageHref(data.page - 1)} icon={ChevronLeft} size="sm">Previous</Button> 36 - {/if} 37 - {#if hasNext} 38 - <Button href={pageHref(data.page + 1)} icon={ChevronRight} iconSide="right" size="sm">Next</Button> 39 - {/if} 30 + {#if data.pageCount > 1} 31 + <div class="mt-4 flex justify-center"> 32 + <Pagination 33 + page={data.page} 34 + total={data.pageCount} 35 + labels 36 + onchange={changePage} 37 + class="gap-5" 38 + /> 40 39 </div> 41 40 {/if}