Compare commits

...

4 Commits

Author SHA1 Message Date
850fb71ddd fix(web): force scroll-to-top on tab switch (iOS Safari quirk)
Next.js App Router *should* reset scroll on route change, but iOS
Safari (and occasionally Chrome on Android) preserves the document
scroll position when navigating between routes that share a layout —
the user lands on the new tab halfway down the page and has to scroll
manually.

Adds a useEffect([]) in both AccountsTable and UsersTable that calls
window.scrollTo({top:0, left:0, behavior:'instant'}) on mount. Route
transitions remount the table component (different page module), so
the empty deps array fires exactly once per visit.

'instant' behavior avoids a smooth-scroll animation that would compete
with the loading-skeleton swap.
2026-05-03 11:51:27 +08:00
9b222eec56 fix(web): drop Refresh button, move Add right-aligned on the title row
- PageHead is now a single column: eyebrow, then a row with the title
  ('Accounts <total>') on the left and the Add pill flush right via
  flex justify-between. Subtitle ('Showing X of Y') sits below.
- Refresh button removed entirely. The internal refresh() function
  stays — the create-account / create-user dialog still calls it on
  success so the new row appears in its sorted position. The 30-second
  cache + revalidateTag-on-mutation already keeps the table fresh
  without a manual button.
- Removed the now-unused 'refreshing' state + the matching dependency
  from the IntersectionObserver effect.
2026-05-03 11:45:53 +08:00
fa93a8c866 fix(web): drop 'keep scrolling to load more' tail from subtitle 2026-05-03 11:43:21 +08:00
ee74ebda64 feat(web): show real DB total in table header (replaces '200+')
The header used to show '200+' once the user had loaded a partial set
of pages — opaque, useless for an operator who actually needs to know
'how many accounts are in the system right now'.

Server (app/cm_api.py):
- /acc/ and /user/ list responses now wrap the rows alongside a
  COUNT(*) of the table: { rows: [...], total: N }. The single-row
  /acc/<username> path is unchanged (still returns Acc[] with one row).
- Each list request issues both queries (the page SELECT and the COUNT)
  on the same pooled connection. COUNT(*) on a 3k-row table is sub-ms;
  even when the cache misses, total request latency stays well under
  20ms on warm-cache MySQL.

Web client:
- web/lib/api.ts: Page<T> gains a  field; getAccountsPage and
  getUsersPage parse the new wrapped response.
- web/app/page.tsx + users/page.tsx: pass page.total down as
  initialTotal.
- web/components/{accounts,users}-table.tsx: hold total in state, sync
  it from every page fetch (initial, loadMore, sort change, force
  refresh) so cm99 monitor inserts during the session bump it correctly.
  Delete decrements it by 1 immediately so the header doesn't lie
  between the optimistic delete and the next refresh.
- PageHead now shows '<total>' as the big number. When loaded < total,
  a small zinc-400 line below reads 'Showing X of N — keep scrolling
  to load more'. Once the user reaches the end, the line goes away.

No new round trips for the count: it piggybacks on the same /acc/?...
or /user/?... request that already fetches the page. The 30s cache
covers the count too — so tab switches still don't hit MySQL.
2026-05-03 11:38:38 +08:00
6 changed files with 119 additions and 111 deletions

View File

@ -122,7 +122,10 @@ class CM_API:
) )
params = [limit, offset] params = [limit, offset]
return jsonify(db.query(query, params)) rows = db.query(query, params)
count_rows = db.query("SELECT COUNT(*) AS c FROM acc", [])
total = int(count_rows[0]["c"]) if count_rows else 0
return jsonify({"rows": rows, "total": total})
except Exception as error: except Exception as error:
return self._handle_error(error, "Not Found"), 404 return self._handle_error(error, "Not Found"), 404
@ -166,7 +169,10 @@ class CM_API:
) )
params = [limit, offset] params = [limit, offset]
return jsonify(db.query(query, params)) rows = db.query(query, params)
count_rows = db.query("SELECT COUNT(*) AS c FROM user", [])
total = int(count_rows[0]["c"]) if count_rows else 0
return jsonify({"rows": rows, "total": total})
except Exception as error: except Exception as error:
return self._handle_error(error, "Not Found"), 404 return self._handle_error(error, "Not Found"), 404

View File

@ -9,6 +9,7 @@ export default async function AccountsPage() {
<AccountsTable <AccountsTable
initial={page.rows} initial={page.rows}
initialHasMore={page.hasMore} initialHasMore={page.hasMore}
initialTotal={page.total}
prefixPattern={PREFIX_PATTERN} prefixPattern={PREFIX_PATTERN}
/> />
); );

View File

@ -13,6 +13,7 @@ export default async function UsersPage() {
<UsersTable <UsersTable
initial={page.rows} initial={page.rows}
initialHasMore={page.hasMore} initialHasMore={page.hasMore}
initialTotal={page.total}
prefixPattern={PREFIX_PATTERN} prefixPattern={PREFIX_PATTERN}
/> />
); );

View File

@ -16,6 +16,7 @@ import Toast, { type ToastMessage } from "./toast";
type Props = { type Props = {
initial: Acc[]; initial: Acc[];
initialHasMore: boolean; initialHasMore: boolean;
initialTotal: number;
prefixPattern: string; prefixPattern: string;
}; };
type SortDir = "asc" | "desc"; type SortDir = "asc" | "desc";
@ -61,12 +62,12 @@ function DeleteButton({
export default function AccountsTable({ export default function AccountsTable({
initial, initial,
initialHasMore, initialHasMore,
initialTotal,
prefixPattern, prefixPattern,
}: Props) { }: Props) {
const [sortDir, setSortDir] = useState<SortDir>("desc"); const [sortDir, setSortDir] = useState<SortDir>("desc");
const [editingKey, setEditingKey] = useState<string | null>(null); const [editingKey, setEditingKey] = useState<string | null>(null);
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null); const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@ -74,9 +75,22 @@ export default function AccountsTable({
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [toast, setToast] = useState<ToastMessage | null>(null); const [toast, setToast] = useState<ToastMessage | null>(null);
// iOS Safari (and sometimes Chrome on Android) keeps the document
// scroll position across SPA route changes, so tab-switching from
// /users back to / leaves the user halfway down the page. Force
// scroll-to-top on mount; route transitions remount the table.
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
}, []);
// Accumulated rows from initial server-side fetch + every loadMore. // Accumulated rows from initial server-side fetch + every loadMore.
const [rows, setRows] = useState<Acc[]>(initial); const [rows, setRows] = useState<Acc[]>(initial);
const [hasMore, setHasMore] = useState<boolean>(initialHasMore); const [hasMore, setHasMore] = useState<boolean>(initialHasMore);
// Total row count in the DB; updated on every page fetch so it stays
// fresh as external writes (the cm99 monitor) add rows. Mutations
// adjust it locally so the header doesn't flash a stale count between
// the optimistic update and the next page-fresh count.
const [total, setTotal] = useState<number>(initialTotal);
const sentinelRef = useRef<HTMLDivElement | null>(null); const sentinelRef = useRef<HTMLDivElement | null>(null);
const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>( const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>(
@ -105,15 +119,15 @@ export default function AccountsTable({
}); });
} }
// Force-evict the cache and re-fetch page 1. Used by the create-success
// path so a freshly added row appears in its sorted position. Keeping
// the function (instead of inlining at the call site) means a future
// 'pull to refresh' gesture has a single hook.
async function refresh() { async function refresh() {
setRefreshing(true);
try {
const page = await refreshAccounts({ prefix: prefixPattern, dir: sortDir }); const page = await refreshAccounts({ prefix: prefixPattern, dir: sortDir });
setRows(page.rows); setRows(page.rows);
setHasMore(page.hasMore); setHasMore(page.hasMore);
} finally { setTotal(page.total);
setRefreshing(false);
}
} }
async function changeSort(next: SortDir) { async function changeSort(next: SortDir) {
@ -128,6 +142,7 @@ export default function AccountsTable({
}); });
setRows(page.rows); setRows(page.rows);
setHasMore(page.hasMore); setHasMore(page.hasMore);
setTotal(page.total);
} finally { } finally {
setLoadingMore(false); setLoadingMore(false);
} }
@ -137,7 +152,7 @@ export default function AccountsTable({
// 300px rootMargin so the next page starts loading before the user // 300px rootMargin so the next page starts loading before the user
// hits the bottom — feels seamless when scrolling fast. // hits the bottom — feels seamless when scrolling fast.
useEffect(() => { useEffect(() => {
if (!hasMore || loadingMore || refreshing) return; if (!hasMore || loadingMore) return;
const sentinel = sentinelRef.current; const sentinel = sentinelRef.current;
if (!sentinel) return; if (!sentinel) return;
@ -153,6 +168,7 @@ export default function AccountsTable({
.then((page) => { .then((page) => {
setRows((prev) => [...prev, ...page.rows]); setRows((prev) => [...prev, ...page.rows]);
setHasMore(page.hasMore); setHasMore(page.hasMore);
setTotal(page.total);
}) })
.catch((err) => console.error("loadMoreAccounts failed:", err)) .catch((err) => console.error("loadMoreAccounts failed:", err))
.finally(() => setLoadingMore(false)); .finally(() => setLoadingMore(false));
@ -161,7 +177,7 @@ export default function AccountsTable({
); );
observer.observe(sentinel); observer.observe(sentinel);
return () => observer.disconnect(); return () => observer.disconnect();
}, [hasMore, loadingMore, refreshing, rows.length, prefixPattern, sortDir]); }, [hasMore, loadingMore, rows.length, prefixPattern, sortDir]);
async function confirmDelete() { async function confirmDelete() {
if (!deleteTarget) return; if (!deleteTarget) return;
@ -172,6 +188,7 @@ export default function AccountsTable({
if (result.ok) { if (result.ok) {
const deleted = deleteTarget; const deleted = deleteTarget;
setRows((prev) => prev.filter((r) => r.username !== deleted)); setRows((prev) => prev.filter((r) => r.username !== deleted));
setTotal((t) => Math.max(0, t - 1));
setDeleteTarget(null); setDeleteTarget(null);
setToast({ type: "success", message: `Account ${deleted} deleted` }); setToast({ type: "success", message: `Account ${deleted} deleted` });
} else { } else {
@ -183,11 +200,8 @@ export default function AccountsTable({
return ( return (
<div> <div>
<PageHead <PageHead
count={0} total={total}
loaded={0} loaded={0}
hasMore={false}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)} onAdd={() => setCreateOpen(true)}
/> />
<div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60"> <div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60">
@ -213,11 +227,8 @@ export default function AccountsTable({
return ( return (
<div> <div>
<PageHead <PageHead
count={optimistic.length} total={total}
loaded={optimistic.length} loaded={optimistic.length}
hasMore={hasMore}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)} onAdd={() => setCreateOpen(true)}
/> />
@ -435,58 +446,45 @@ function CardRow({ label, children }: { label: string; children: React.ReactNode
} }
function PageHead({ function PageHead({
count, total,
loaded, loaded,
hasMore,
onRefresh,
refreshing,
onAdd, onAdd,
}: { }: {
count: number; total: number;
loaded: number; loaded: number;
hasMore: boolean;
onRefresh: () => void;
refreshing: boolean;
onAdd: () => void; onAdd: () => void;
}) { }) {
// count == loaded for now; kept separate so a future "showing X of Y" // total = COUNT(*) from the API; loaded = how many rows the user has
// header (when we surface a server-side total) drops in cleanly. // scrolled in so far. Show the partial count only while it differs
const showHasMore = hasMore && loaded > 0; // from the total, so a fully-scrolled list reads cleanly.
const showLoaded = loaded > 0 && loaded < total;
return ( return (
<div className="flex flex-wrap items-end justify-between gap-4">
<div> <div>
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500"> <p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
Table Table
</p> </p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl"> <div className="mt-1 flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl">
Accounts Accounts
<span className="ml-2 align-middle text-base font-medium text-zinc-400"> <span className="ml-2 align-middle text-base font-medium text-zinc-400">
{count} {total}
{showHasMore && <span className="text-zinc-300">+</span>}
</span> </span>
</h1> </h1>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onRefresh}
disabled={refreshing}
className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-medium text-zinc-700 shadow-sm ring-1 ring-zinc-200 transition-colors hover:bg-zinc-50 hover:text-zinc-900 disabled:opacity-60"
>
<span aria-hidden="true" className={refreshing ? "inline-block animate-spin" : ""}>
</span>
Refresh
</button>
<button <button
type="button" type="button"
onClick={onAdd} onClick={onAdd}
className="inline-flex items-center gap-1.5 rounded-full bg-zinc-900 px-4 py-2 text-xs font-medium text-white shadow-sm transition-colors hover:bg-zinc-700" aria-label="Add account"
className="inline-flex items-center gap-1.5 rounded-full bg-zinc-900 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-colors hover:bg-zinc-700"
> >
<span aria-hidden="true">+</span> <span aria-hidden="true">+</span>
Add Add
</button> </button>
</div> </div>
{showLoaded && (
<p className="mt-1 text-[11px] text-zinc-400">
Showing {loaded} of {total}
</p>
)}
</div> </div>
); );
} }

View File

@ -16,6 +16,7 @@ import Toast, { type ToastMessage } from "./toast";
type Props = { type Props = {
initial: User[]; initial: User[];
initialHasMore: boolean; initialHasMore: boolean;
initialTotal: number;
prefixPattern: string; prefixPattern: string;
}; };
type SortDir = "asc" | "desc"; type SortDir = "asc" | "desc";
@ -62,13 +63,13 @@ function DeleteButton({
export default function UsersTable({ export default function UsersTable({
initial, initial,
initialHasMore, initialHasMore,
initialTotal,
prefixPattern, prefixPattern,
}: Props) { }: Props) {
const [sortKey, setSortKey] = useState<SortKey>("last_update_time"); const [sortKey, setSortKey] = useState<SortKey>("last_update_time");
const [sortDir, setSortDir] = useState<SortDir>("desc"); const [sortDir, setSortDir] = useState<SortDir>("desc");
const [editingKey, setEditingKey] = useState<string | null>(null); const [editingKey, setEditingKey] = useState<string | null>(null);
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null); const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@ -76,8 +77,16 @@ export default function UsersTable({
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [toast, setToast] = useState<ToastMessage | null>(null); const [toast, setToast] = useState<ToastMessage | null>(null);
// Force scroll-to-top on mount — iOS Safari preserves document scroll
// across SPA route changes, so tab-switching leaves the new page
// halfway down. Route transitions remount the table, so [] deps fire.
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
}, []);
const [rows, setRows] = useState<User[]>(initial); const [rows, setRows] = useState<User[]>(initial);
const [hasMore, setHasMore] = useState<boolean>(initialHasMore); const [hasMore, setHasMore] = useState<boolean>(initialHasMore);
const [total, setTotal] = useState<number>(initialTotal);
const sentinelRef = useRef<HTMLDivElement | null>(null); const sentinelRef = useRef<HTMLDivElement | null>(null);
const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>( const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>(
@ -120,9 +129,9 @@ export default function UsersTable({
}); });
} }
// Force-evict the cache and re-fetch page 1. Used by the create-success
// path so a freshly added row appears in its sorted position.
async function refresh() { async function refresh() {
setRefreshing(true);
try {
const page = await refreshUsers({ const page = await refreshUsers({
prefix: prefixPattern, prefix: prefixPattern,
sort: sortKey, sort: sortKey,
@ -130,9 +139,7 @@ export default function UsersTable({
}); });
setRows(page.rows); setRows(page.rows);
setHasMore(page.hasMore); setHasMore(page.hasMore);
} finally { setTotal(page.total);
setRefreshing(false);
}
} }
async function changeSort(nextKey: SortKey) { async function changeSort(nextKey: SortKey) {
@ -154,13 +161,14 @@ export default function UsersTable({
}); });
setRows(page.rows); setRows(page.rows);
setHasMore(page.hasMore); setHasMore(page.hasMore);
setTotal(page.total);
} finally { } finally {
setLoadingMore(false); setLoadingMore(false);
} }
} }
useEffect(() => { useEffect(() => {
if (!hasMore || loadingMore || refreshing) return; if (!hasMore || loadingMore) return;
const sentinel = sentinelRef.current; const sentinel = sentinelRef.current;
if (!sentinel) return; if (!sentinel) return;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
@ -176,6 +184,7 @@ export default function UsersTable({
.then((page) => { .then((page) => {
setRows((prev) => [...prev, ...page.rows]); setRows((prev) => [...prev, ...page.rows]);
setHasMore(page.hasMore); setHasMore(page.hasMore);
setTotal(page.total);
}) })
.catch((err) => console.error("loadMoreUsers failed:", err)) .catch((err) => console.error("loadMoreUsers failed:", err))
.finally(() => setLoadingMore(false)); .finally(() => setLoadingMore(false));
@ -184,7 +193,7 @@ export default function UsersTable({
); );
observer.observe(sentinel); observer.observe(sentinel);
return () => observer.disconnect(); return () => observer.disconnect();
}, [hasMore, loadingMore, refreshing, rows.length, prefixPattern, sortKey, sortDir]); }, [hasMore, loadingMore, rows.length, prefixPattern, sortKey, sortDir]);
async function confirmDelete() { async function confirmDelete() {
if (!deleteTarget) return; if (!deleteTarget) return;
@ -195,6 +204,7 @@ export default function UsersTable({
if (result.ok) { if (result.ok) {
const deleted = deleteTarget; const deleted = deleteTarget;
setRows((prev) => prev.filter((r) => r.f_username !== deleted)); setRows((prev) => prev.filter((r) => r.f_username !== deleted));
setTotal((t) => Math.max(0, t - 1));
setDeleteTarget(null); setDeleteTarget(null);
setToast({ type: "success", message: `User ${deleted} deleted` }); setToast({ type: "success", message: `User ${deleted} deleted` });
} else { } else {
@ -222,10 +232,8 @@ export default function UsersTable({
return ( return (
<div> <div>
<PageHead <PageHead
count={0} total={total}
hasMore={false} loaded={0}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)} onAdd={() => setCreateOpen(true)}
/> />
<div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60"> <div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60">
@ -249,10 +257,8 @@ export default function UsersTable({
return ( return (
<div> <div>
<PageHead <PageHead
count={optimistic.length} total={total}
hasMore={hasMore} loaded={optimistic.length}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)} onAdd={() => setCreateOpen(true)}
/> />
@ -459,52 +465,40 @@ function MobileRow({ label, children }: { label: string; children: React.ReactNo
} }
function PageHead({ function PageHead({
count, total,
hasMore, loaded,
onRefresh,
refreshing,
onAdd, onAdd,
}: { }: {
count: number; total: number;
hasMore: boolean; loaded: number;
onRefresh: () => void;
refreshing: boolean;
onAdd: () => void; onAdd: () => void;
}) { }) {
const showHasMore = hasMore && count > 0; const showLoaded = loaded > 0 && loaded < total;
return ( return (
<div className="flex flex-wrap items-end justify-between gap-4">
<div> <div>
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Table</p> <p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Table</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl"> <div className="mt-1 flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl">
Users Users
<span className="ml-2 align-middle text-base font-medium text-zinc-400"> <span className="ml-2 align-middle text-base font-medium text-zinc-400">
{count} {total}
{showHasMore && <span className="text-zinc-300">+</span>}
</span> </span>
</h1> </h1>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onRefresh}
disabled={refreshing}
className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-medium text-zinc-700 shadow-sm ring-1 ring-zinc-200 transition-colors hover:bg-zinc-50 hover:text-zinc-900 disabled:opacity-60"
>
<span aria-hidden="true" className={refreshing ? "inline-block animate-spin" : ""}>
</span>
Refresh
</button>
<button <button
type="button" type="button"
onClick={onAdd} onClick={onAdd}
className="inline-flex items-center gap-1.5 rounded-full bg-zinc-900 px-4 py-2 text-xs font-medium text-white shadow-sm transition-colors hover:bg-zinc-700" aria-label="Add user"
className="inline-flex items-center gap-1.5 rounded-full bg-zinc-900 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-colors hover:bg-zinc-700"
> >
<span aria-hidden="true">+</span> <span aria-hidden="true">+</span>
Add Add
</button> </button>
</div> </div>
{showLoaded && (
<p className="mt-1 text-[11px] text-zinc-400">
Showing {loaded} of {total}
</p>
)}
</div> </div>
); );
} }

View File

@ -15,7 +15,7 @@ export const USERS_TAG = "users";
// in well under one frame even on phones. // in well under one frame even on phones.
export const PAGE_SIZE = 200; export const PAGE_SIZE = 200;
export type Page<T> = { rows: T[]; hasMore: boolean }; export type Page<T> = { rows: T[]; hasMore: boolean; total: number };
export type AccountsPageOpts = { export type AccountsPageOpts = {
offset?: number; offset?: number;
@ -82,13 +82,21 @@ function buildUsersUrl(opts: UsersPageOpts): string {
export async function getAccountsPage(opts: AccountsPageOpts = {}): Promise<Page<Acc>> { export async function getAccountsPage(opts: AccountsPageOpts = {}): Promise<Page<Acc>> {
const data = (await fetchApi(buildAccountsUrl(opts), { const data = (await fetchApi(buildAccountsUrl(opts), {
next: { revalidate: CACHE_REVALIDATE_SECONDS, tags: [ACCOUNTS_TAG] }, next: { revalidate: CACHE_REVALIDATE_SECONDS, tags: [ACCOUNTS_TAG] },
})) as Acc[]; })) as { rows: Acc[]; total: number };
return { rows: data, hasMore: data.length === PAGE_SIZE }; return {
rows: data.rows,
hasMore: data.rows.length === PAGE_SIZE,
total: data.total,
};
} }
export async function getUsersPage(opts: UsersPageOpts = {}): Promise<Page<User>> { export async function getUsersPage(opts: UsersPageOpts = {}): Promise<Page<User>> {
const data = (await fetchApi(buildUsersUrl(opts), { const data = (await fetchApi(buildUsersUrl(opts), {
next: { revalidate: CACHE_REVALIDATE_SECONDS, tags: [USERS_TAG] }, next: { revalidate: CACHE_REVALIDATE_SECONDS, tags: [USERS_TAG] },
})) as User[]; })) as { rows: User[]; total: number };
return { rows: data, hasMore: data.length === PAGE_SIZE }; return {
rows: data.rows,
hasMore: data.rows.length === PAGE_SIZE,
total: data.total,
};
} }