updated accounts page
This commit is contained in:
parent
d7fd75e487
commit
030f859ba3
9 changed files with 221 additions and 48 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
|
|
@ -8,82 +8,142 @@ import { useI18n } from "../i18n";
|
|||
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
|
||||
import { accountMetrics } from "../lib/metrics";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountListResponse } from "../types";
|
||||
import type { AccountListResponse, AccountStatsResponse } from "../types";
|
||||
|
||||
type Cursor = { beforeID: number; beforeActiveUS: number };
|
||||
type AccountPageSize = 10 | 20 | 50 | 100;
|
||||
|
||||
const zeroCursor: Cursor = { beforeID: 0, beforeActiveUS: 0 };
|
||||
|
||||
export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const [q, setQ] = useState("");
|
||||
const [limit, setLimit] = useState("50");
|
||||
const [limit, setLimit] = useState<AccountPageSize>(50);
|
||||
const [data, setData] = useState<AccountListResponse | null>(null);
|
||||
const [cursor, setCursor] = useState({ beforeID: 0, beforeActiveUS: 0 });
|
||||
const [stats, setStats] = useState<AccountStatsResponse | null>(null);
|
||||
// history holds the cursor used to reach every page before the current
|
||||
// one, so "Previous" can pop back without re-deriving offsets -- keyset
|
||||
// pagination has no notion of "page N" to jump back to otherwise.
|
||||
const [history, setHistory] = useState<Cursor[]>([]);
|
||||
const [cursor, setCursor] = useState<Cursor>(zeroCursor);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load(next = false) {
|
||||
async function fetchPage(query: string, at: Cursor) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit });
|
||||
if (q.trim()) {
|
||||
params.set("q", q.trim());
|
||||
} else if (next) {
|
||||
params.set("before_id", String(cursor.beforeID));
|
||||
params.set("before_active_us", String(cursor.beforeActiveUS));
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (query.trim()) {
|
||||
params.set("q", query.trim());
|
||||
}
|
||||
if (at.beforeID || at.beforeActiveUS) {
|
||||
params.set("before_id", String(at.beforeID));
|
||||
params.set("before_active_us", String(at.beforeActiveUS));
|
||||
}
|
||||
try {
|
||||
const result = await api.accounts(params);
|
||||
setData(result);
|
||||
setCursor({
|
||||
beforeID: result.next_before_id,
|
||||
beforeActiveUS: result.next_before_active_us
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
return null;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFresh() {
|
||||
setHistory([]);
|
||||
setCursor(zeroCursor);
|
||||
await fetchPage(q, zeroCursor);
|
||||
}
|
||||
|
||||
async function loadNext() {
|
||||
if (!data?.has_more) return;
|
||||
const at = { beforeID: data.next_before_id, beforeActiveUS: data.next_before_active_us };
|
||||
const result = await fetchPage(q, at);
|
||||
if (result) {
|
||||
setHistory((prev) => [...prev, cursor]);
|
||||
setCursor(at);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrev() {
|
||||
if (history.length === 0) return;
|
||||
const at = history[history.length - 1];
|
||||
const result = await fetchPage(q, at);
|
||||
if (result) {
|
||||
setHistory((prev) => prev.slice(0, -1));
|
||||
setCursor(at);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
setStats(await api.accountStats());
|
||||
} catch {
|
||||
// Stats are a header nicety; a failure here shouldn't block the list.
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
void loadFresh();
|
||||
void loadStats();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const metrics = accountMetrics(data?.rows ?? []);
|
||||
const canGoPrev = history.length > 0 && !busy;
|
||||
const canGoNext = Boolean(data?.has_more) && !busy;
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={t("account.pageTitle")}
|
||||
eyebrow={data?.listing === false ? t("account.queryResults") : t("account.recentActive")}
|
||||
actions={
|
||||
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void loadFresh();
|
||||
void loadStats();
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
<RefreshCw size={15} /> {t("common.refresh")}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={t("account.currentPage")} value={String(data?.rows.length ?? 0)} />
|
||||
<Metric label={t("account.totalUsers")} value={stats ? String(stats.total) : "…"} />
|
||||
<Metric label={t("account.onlineNow")} value={stats ? String(stats.online) : "…"} tone="good" />
|
||||
<Metric label={t("account.onlineDevices")} value={String(metrics.devices)} />
|
||||
<Metric label={t("account.premium")} value={String(metrics.premium)} tone="good" />
|
||||
<Metric label={t("account.frozen")} value={String(metrics.frozen)} tone={metrics.frozen > 0 ? "danger" : "neutral"} />
|
||||
</div>
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void loadFresh(); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("account.searchPlaceholder")} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<label className="gift-page-size">
|
||||
<span>{t("common.limit")}</span>
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
|
||||
<select value={String(limit)} onChange={(event) => setLimit(Number(event.target.value) as AccountPageSize)}>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
|
||||
</button>
|
||||
{data?.listing && data.has_more && (
|
||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
||||
<ChevronRight size={15} /> {t("messages.nextPage")}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn icon-text" type="button" onClick={() => void loadPrev()} disabled={!canGoPrev}>
|
||||
<ChevronLeft size={15} /> {t("common.previous")}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={() => void loadNext()} disabled={!canGoNext}>
|
||||
<ChevronRight size={15} /> {t("common.next")}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
<div className="table-wrap">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue