SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
4.3 KB · 107 lines tsx
Raw Blame History
1"use client";23import * as React from "react";4import { useRouter } from "next/navigation";5import { Search } from "lucide-react";6import { useAdminQuery } from "@/components/admin/use-query";7import type { UserListRow, UsersResponse } from "@/components/admin/types";8import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TableSkeleton, Pagination, DenseInput, DenseSelect, UserStatusPill, type Column } from "@/components/admin/primitives";9import { dateTime, int, sc } from "@/components/admin/format";10import { timeAgo } from "@/lib/utils";1112const SORTS = [13  { value: "created", label: "Newest" },14  { value: "spins", label: "Most spins" },15  { value: "balance", label: "Highest balance" },16  { value: "level", label: "Highest level" },17] as const;18type Sort = (typeof SORTS)[number]["value"];1920const LIMIT = 50;2122const cols: Column<UserListRow>[] = [23  { key: "username", header: "Username", render: (r) => <span className="font-medium text-fg">{r.username}</span> },24  { key: "level", header: "Level", align: "right", render: (r) => r.level },25  { key: "balance", header: "Balance", align: "right", render: (r) => <span className="text-credit">{sc(r.balance)}</span> },26  { key: "spins", header: "Spins", align: "right", render: (r) => int(r.totalSpins) },27  { key: "wagered", header: "Wagered", align: "right", render: (r) => sc(r.wagered) },28  { key: "won", header: "Won", align: "right", render: (r) => sc(r.won) },29  { key: "status", header: "Status", render: (r) => <UserStatusPill status={r.status} /> },30  { key: "created", header: "Created", render: (r) => <span className="text-fg-3">{dateTime(r.createdAt)}</span> },31  { key: "lastLogin", header: "Last login", render: (r) => <span className="text-fg-3">{r.lastLoginAt ? timeAgo(r.lastLoginAt) : "never"}</span> },32];3334function useDebounced<T>(value: T, delay = 300): T {35  const [v, setV] = React.useState(value);36  React.useEffect(() => {37    const t = setTimeout(() => setV(value), delay);38    return () => clearTimeout(t);39  }, [value, delay]);40  return v;41}4243export default function AdminUsersPage() {44  const router = useRouter();45  const [q, setQ] = React.useState("");46  const [sort, setSort] = React.useState<Sort>("created");47  const [offset, setOffset] = React.useState(0);48  const dq = useDebounced(q.trim());4950  const params = new URLSearchParams({ sort, limit: String(LIMIT), offset: String(offset) });51  if (dq) params.set("q", dq);52  const query = useAdminQuery<UsersResponse>(`/api/admin/users?${params.toString()}`);53  const data = query.data;5455  return (56    <>57      <PageHeader title="Users" description="Search, inspect and moderate player accounts." actions={<RefreshButton onClick={() => void query.refresh()} loading={query.refreshing} />} />5859      <div className="mb-3 flex flex-wrap items-center gap-2">60        <div className="relative w-full sm:w-72">61          <Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-4" />62          <DenseInput63            className="pl-8"64            placeholder="Search username…"65            value={q}66            onChange={(e) => {67              setQ(e.target.value);68              setOffset(0);69            }}70            aria-label="Search users"71          />72        </div>73        <DenseSelect74          value={sort}75          onChange={(e) => {76            setSort(e.target.value as Sort);77            setOffset(0);78          }}79          aria-label="Sort users"80        >81          {SORTS.map((s) => (82            <option key={s.value} value={s.value}>83              {s.label}84            </option>85          ))}86        </DenseSelect>87        {data ? <span className="ml-auto text-[12px] text-fg-3 tabular">{int(data.total)} accounts</span> : null}88      </div>8990      <Panel padded={false}>91        {query.error && !data ? (92          <div className="p-4">93            <ErrorState error={query.error} onRetry={() => void query.refresh()} />94          </div>95        ) : !data ? (96          <TableSkeleton rows={10} cols={9} />97        ) : (98          <>99            <DataTable columns={cols} rows={data.users} rowKey={(r) => r.id} dense stale={query.stale} onRowClick={(r) => router.push(`/admin/users/${r.id}`)} empty={dq ? `No users match "${dq}".` : "No registered users yet."} />100            <Pagination offset={offset} limit={LIMIT} total={data.total} onChange={setOffset} />101          </>102        )}103      </Panel>104    </>105  );106}107