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.
This commit is contained in:
yiekheng 2026-05-03 11:45:53 +08:00
parent fa93a8c866
commit 9b222eec56
2 changed files with 46 additions and 92 deletions

View File

@ -68,7 +68,6 @@ export default function AccountsTable({
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);
@ -112,16 +111,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); const page = await refreshAccounts({ prefix: prefixPattern, dir: sortDir });
try { setRows(page.rows);
const page = await refreshAccounts({ prefix: prefixPattern, dir: sortDir }); setHasMore(page.hasMore);
setRows(page.rows); setTotal(page.total);
setHasMore(page.hasMore);
setTotal(page.total);
} finally {
setRefreshing(false);
}
} }
async function changeSort(next: SortDir) { async function changeSort(next: SortDir) {
@ -146,7 +144,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;
@ -171,7 +169,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;
@ -196,8 +194,6 @@ export default function AccountsTable({
<PageHead <PageHead
total={total} total={total}
loaded={0} 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">
@ -225,8 +221,6 @@ export default function AccountsTable({
<PageHead <PageHead
total={total} total={total}
loaded={optimistic.length} loaded={optimistic.length}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)} onAdd={() => setCreateOpen(true)}
/> />
@ -446,14 +440,10 @@ function CardRow({ label, children }: { label: string; children: React.ReactNode
function PageHead({ function PageHead({
total, total,
loaded, loaded,
onRefresh,
refreshing,
onAdd, onAdd,
}: { }: {
total: number; total: number;
loaded: number; loaded: number;
onRefresh: () => void;
refreshing: boolean;
onAdd: () => void; onAdd: () => void;
}) { }) {
// total = COUNT(*) from the API; loaded = how many rows the user has // total = COUNT(*) from the API; loaded = how many rows the user has
@ -461,44 +451,32 @@ function PageHead({
// from the total, so a fully-scrolled list reads cleanly. // from the total, so a fully-scrolled list reads cleanly.
const showLoaded = loaded > 0 && loaded < total; 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> <div className="mt-1 flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl"> <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">
{total} {total}
</span> </span>
</h1> </h1>
{showLoaded && (
<p className="mt-1 text-[11px] text-zinc-400">
Showing {loaded} of {total}
</p>
)}
</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

@ -70,7 +70,6 @@ export default function UsersTable({
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);
@ -123,20 +122,17 @@ 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); const page = await refreshUsers({
try { prefix: prefixPattern,
const page = await refreshUsers({ sort: sortKey,
prefix: prefixPattern, dir: sortDir,
sort: sortKey, });
dir: sortDir, setRows(page.rows);
}); setHasMore(page.hasMore);
setRows(page.rows); setTotal(page.total);
setHasMore(page.hasMore);
setTotal(page.total);
} finally {
setRefreshing(false);
}
} }
async function changeSort(nextKey: SortKey) { async function changeSort(nextKey: SortKey) {
@ -165,7 +161,7 @@ export default function UsersTable({
} }
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(
@ -190,7 +186,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;
@ -231,8 +227,6 @@ export default function UsersTable({
<PageHead <PageHead
total={total} total={total}
loaded={0} 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">
@ -258,8 +252,6 @@ export default function UsersTable({
<PageHead <PageHead
total={total} total={total}
loaded={optimistic.length} loaded={optimistic.length}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)} onAdd={() => setCreateOpen(true)}
/> />
@ -468,54 +460,38 @@ function MobileRow({ label, children }: { label: string; children: React.ReactNo
function PageHead({ function PageHead({
total, total,
loaded, loaded,
onRefresh,
refreshing,
onAdd, onAdd,
}: { }: {
total: number; total: number;
loaded: number; loaded: number;
onRefresh: () => void;
refreshing: boolean;
onAdd: () => void; onAdd: () => void;
}) { }) {
const showLoaded = loaded > 0 && loaded < total; 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> <div className="mt-1 flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl"> <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">
{total} {total}
</span> </span>
</h1> </h1>
{showLoaded && (
<p className="mt-1 text-[11px] text-zinc-400">
Showing {loaded} of {total}
</p>
)}
</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>
); );
} }