Compare commits
4 Commits
f485dc52aa
...
850fb71ddd
| Author | SHA1 | Date | |
|---|---|---|---|
| 850fb71ddd | |||
| 9b222eec56 | |||
| fa93a8c866 | |||
| ee74ebda64 |
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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);
|
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);
|
|
||||||
} finally {
|
|
||||||
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>
|
<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">
|
||||||
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,19 +129,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);
|
|
||||||
} finally {
|
|
||||||
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>
|
<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">
|
||||||
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user