Three operator-quality-of-life features behind the same caching and
pagination contract that the existing tables already use:
- Search bar on /acc and /users (Find/Enter to apply, Clear to reset).
Backed by a new `q` API param that filters via WHERE
username/f_username LIKE 'q%' on both rows + count queries so the
table header total stays consistent under a filter.
- Two more sortable columns: acc.status and user.t_username. Sort
columns are whitelisted because sort_col is f-string'd into ORDER
BY (parameterised binding doesn't apply to column names) — anything
outside the allowed set falls back to the table's default.
- Copy button on every row that writes a multi-line credentials
message to the clipboard. New lib/clipboard.ts helper tries
navigator.clipboard.writeText() first and falls back to
textarea+execCommand("copy") so it works over the internal-network
HTTP deploy where the modern API is gated by secure-context rules.
Acc message: Username/Password (+Link if set). User message:
From/To username and password.
Also: inactive sort indicators now render ↓ (the direction they'll
sort on first click) instead of the more ambiguous ↕.
Test suite grows from 53 to 70: tests/test_user_search_filter.py
(9 tests) pins the q-filter contract on both /user/ and /acc/;
tests/test_sort_whitelist.py (8 tests) pins the allowed sort columns
and proves out-of-set values cannot reach the SQL parser.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
712 lines
24 KiB
TypeScript
712 lines
24 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useOptimistic, useRef, useState, useTransition } from "react";
|
||
import type { Acc } from "@/lib/types";
|
||
import {
|
||
deleteAccount,
|
||
loadMoreAccounts,
|
||
refreshAccounts,
|
||
updateAccount,
|
||
} from "@/app/actions";
|
||
import EditableCell from "./editable-cell";
|
||
import ConfirmDialog from "./confirm-dialog";
|
||
import CreateAccountDialog from "./create-account-dialog";
|
||
import Toast, { type ToastMessage } from "./toast";
|
||
import { copyToClipboard } from "@/lib/clipboard";
|
||
|
||
type Props = {
|
||
initial: Acc[];
|
||
initialHasMore: boolean;
|
||
initialTotal: number;
|
||
prefixPattern: string;
|
||
};
|
||
type SortDir = "asc" | "desc";
|
||
type SortKey = "username" | "status";
|
||
type OptimisticPatch = { username: string; field: keyof Acc; value: string };
|
||
|
||
function StatusBadge({ status }: { status: string }) {
|
||
const map: Record<string, { bg: string; fg: string; label: string }> = {
|
||
"": { bg: "bg-zinc-100", fg: "text-zinc-600", label: "available" },
|
||
wait: { bg: "bg-amber-100", fg: "text-amber-700", label: "wait" },
|
||
done: { bg: "bg-emerald-100", fg: "text-emerald-700", label: "done" },
|
||
};
|
||
const v = map[status] ?? { bg: "bg-zinc-100", fg: "text-zinc-600", label: status || "—" };
|
||
return (
|
||
<span
|
||
className={`inline-flex items-center rounded-full ${v.bg} ${v.fg} px-2 py-0.5 text-[11px] font-medium`}
|
||
>
|
||
{v.label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function DeleteButton({
|
||
label,
|
||
onClick,
|
||
}: {
|
||
label: string;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
aria-label={`Delete ${label}`}
|
||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-red-50 hover:text-red-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||
>
|
||
<span aria-hidden="true" className="text-base leading-none">
|
||
×
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function CopyButton({
|
||
label,
|
||
onClick,
|
||
}: {
|
||
label: string;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
aria-label={`Copy credentials for ${label}`}
|
||
title="Copy username and password"
|
||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400"
|
||
>
|
||
<svg
|
||
aria-hidden="true"
|
||
viewBox="0 0 16 16"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
className="h-4 w-4"
|
||
>
|
||
<rect x="5.5" y="5.5" width="8" height="8" rx="1.25" />
|
||
<path d="M3 10.5V3.5A1 1 0 0 1 4 2.5h7" />
|
||
</svg>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export default function AccountsTable({
|
||
initial,
|
||
initialHasMore,
|
||
initialTotal,
|
||
prefixPattern,
|
||
}: Props) {
|
||
const [sortKey, setSortKey] = useState<SortKey>("username");
|
||
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||
const [, startTransition] = useTransition();
|
||
const [loadingMore, setLoadingMore] = useState(false);
|
||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||
const [deleting, setDeleting] = useState(false);
|
||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
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.
|
||
const [rows, setRows] = useState<Acc[]>(initial);
|
||
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);
|
||
|
||
// `searchInput` is what the user is typing; `appliedQuery` is what
|
||
// the server actually filtered on. Decoupled so typing doesn't fire a
|
||
// request per keystroke — only on Enter / Find / Clear. `appliedQuery`
|
||
// flows into refresh / changeSort / loadMore so sort + infinite-scroll
|
||
// respect the active search.
|
||
const [searchInput, setSearchInput] = useState("");
|
||
const [appliedQuery, setAppliedQuery] = useState("");
|
||
const [searching, setSearching] = useState(false);
|
||
|
||
const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>(
|
||
rows,
|
||
(state, patch) =>
|
||
state.map((row) =>
|
||
row.username === patch.username ? { ...row, [patch.field]: patch.value } : row,
|
||
),
|
||
);
|
||
|
||
function saveCell(username: string, field: keyof Acc, value: string) {
|
||
return new Promise<{ ok: boolean; error?: string }>((resolve) => {
|
||
startTransition(async () => {
|
||
applyOptimistic({ username, field, value });
|
||
const row = rows.find((r) => r.username === username);
|
||
if (!row) return resolve({ ok: false, error: "row not found" });
|
||
const next: Acc = { ...row, [field]: value };
|
||
const result = await updateAccount(next);
|
||
if (result.ok) {
|
||
setRows((prev) => prev.map((r) => (r.username === username ? next : r)));
|
||
resolve({ ok: true });
|
||
} else {
|
||
resolve({ ok: false, error: result.error });
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// 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() {
|
||
const page = await refreshAccounts({
|
||
prefix: prefixPattern,
|
||
sort: sortKey,
|
||
dir: sortDir,
|
||
q: appliedQuery,
|
||
});
|
||
setRows(page.rows);
|
||
setHasMore(page.hasMore);
|
||
setTotal(page.total);
|
||
}
|
||
|
||
async function applySearch(nextQuery: string) {
|
||
setSearching(true);
|
||
setAppliedQuery(nextQuery);
|
||
try {
|
||
const page = await refreshAccounts({
|
||
prefix: prefixPattern,
|
||
sort: sortKey,
|
||
dir: sortDir,
|
||
q: nextQuery,
|
||
});
|
||
setRows(page.rows);
|
||
setHasMore(page.hasMore);
|
||
setTotal(page.total);
|
||
} finally {
|
||
setSearching(false);
|
||
}
|
||
}
|
||
|
||
async function changeSort(nextKey: SortKey) {
|
||
let nextDir: SortDir;
|
||
if (nextKey === sortKey) {
|
||
nextDir = sortDir === "asc" ? "desc" : "asc";
|
||
} else {
|
||
nextDir = "desc";
|
||
}
|
||
setSortKey(nextKey);
|
||
setSortDir(nextDir);
|
||
setLoadingMore(true);
|
||
try {
|
||
const page = await loadMoreAccounts({
|
||
offset: 0,
|
||
prefix: prefixPattern,
|
||
sort: nextKey,
|
||
dir: nextDir,
|
||
q: appliedQuery,
|
||
});
|
||
setRows(page.rows);
|
||
setHasMore(page.hasMore);
|
||
setTotal(page.total);
|
||
} finally {
|
||
setLoadingMore(false);
|
||
}
|
||
}
|
||
|
||
// Infinite scroll: when sentinel enters viewport, fetch the next page.
|
||
// 300px rootMargin so the next page starts loading before the user
|
||
// hits the bottom — feels seamless when scrolling fast.
|
||
useEffect(() => {
|
||
if (!hasMore || loadingMore) return;
|
||
const sentinel = sentinelRef.current;
|
||
if (!sentinel) return;
|
||
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
if (!entries.some((e) => e.isIntersecting)) return;
|
||
setLoadingMore(true);
|
||
loadMoreAccounts({
|
||
offset: rows.length,
|
||
prefix: prefixPattern,
|
||
sort: sortKey,
|
||
dir: sortDir,
|
||
q: appliedQuery,
|
||
})
|
||
.then((page) => {
|
||
setRows((prev) => [...prev, ...page.rows]);
|
||
setHasMore(page.hasMore);
|
||
setTotal(page.total);
|
||
})
|
||
.catch((err) => console.error("loadMoreAccounts failed:", err))
|
||
.finally(() => setLoadingMore(false));
|
||
},
|
||
{ rootMargin: "300px 0px" },
|
||
);
|
||
observer.observe(sentinel);
|
||
return () => observer.disconnect();
|
||
}, [hasMore, loadingMore, rows.length, prefixPattern, sortKey, sortDir, appliedQuery]);
|
||
|
||
async function handleCopy(row: Acc) {
|
||
// Build the message line-by-line so an empty `link` (which the
|
||
// monitor sometimes leaves blank for new accounts) drops out
|
||
// entirely instead of producing a dangling "Link:" with nothing
|
||
// after it.
|
||
const lines = [
|
||
`Username: ${row.username}`,
|
||
`Password: ${row.password}`,
|
||
];
|
||
if (row.link) lines.push(`Link: ${row.link}`);
|
||
const text = lines.join("\n");
|
||
const ok = await copyToClipboard(text);
|
||
setToast(
|
||
ok
|
||
? { type: "success", message: `Copied credentials for ${row.username}` }
|
||
: {
|
||
type: "error",
|
||
message: `Could not copy — clipboard access blocked. Select text manually.`,
|
||
},
|
||
);
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
if (!deleteTarget) return;
|
||
setDeleting(true);
|
||
setDeleteError(null);
|
||
const result = await deleteAccount(deleteTarget);
|
||
setDeleting(false);
|
||
if (result.ok) {
|
||
const deleted = deleteTarget;
|
||
setRows((prev) => prev.filter((r) => r.username !== deleted));
|
||
setTotal((t) => Math.max(0, t - 1));
|
||
setDeleteTarget(null);
|
||
setToast({ type: "success", message: `Account ${deleted} deleted` });
|
||
} else {
|
||
setDeleteError(result.error);
|
||
}
|
||
}
|
||
|
||
const onSearchSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
void applySearch(searchInput.trim());
|
||
};
|
||
const onSearchClear = () => {
|
||
setSearchInput("");
|
||
void applySearch("");
|
||
};
|
||
|
||
function HeaderTh({ k, label }: { k: SortKey; label: string }) {
|
||
const active = sortKey === k;
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => changeSort(k)}
|
||
className={`inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider transition-colors ${
|
||
active ? "text-zinc-900" : "text-zinc-500 hover:text-zinc-900"
|
||
}`}
|
||
>
|
||
{label}
|
||
<span aria-hidden="true">{active && sortDir === "asc" ? "↑" : "↓"}</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
if (rows.length === 0 && !appliedQuery) {
|
||
return (
|
||
<div>
|
||
<PageHead
|
||
total={total}
|
||
loaded={0}
|
||
onAdd={() => setCreateOpen(true)}
|
||
/>
|
||
<SearchBar
|
||
value={searchInput}
|
||
onChange={setSearchInput}
|
||
onSubmit={onSearchSubmit}
|
||
onClear={onSearchClear}
|
||
appliedQuery={appliedQuery}
|
||
searching={searching}
|
||
/>
|
||
<div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60">
|
||
<p className="text-sm text-zinc-500">
|
||
No accounts yet. Click <span className="font-medium text-zinc-700">Add</span> above to create one manually, or wait for the monitor.
|
||
</p>
|
||
</div>
|
||
<CreateAccountDialog
|
||
open={createOpen}
|
||
onClose={() => setCreateOpen(false)}
|
||
onSuccess={async (name) => {
|
||
setToast({ type: "success", message: `Account ${name} created` });
|
||
// Force-refresh from page 1 so the new row appears in its
|
||
// sorted position. (We don't know where it ranks otherwise.)
|
||
await refresh();
|
||
}}
|
||
prefixPattern={prefixPattern}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<PageHead
|
||
total={total}
|
||
loaded={optimistic.length}
|
||
onAdd={() => setCreateOpen(true)}
|
||
/>
|
||
<SearchBar
|
||
value={searchInput}
|
||
onChange={setSearchInput}
|
||
onSubmit={onSearchSubmit}
|
||
onClear={onSearchClear}
|
||
appliedQuery={appliedQuery}
|
||
searching={searching}
|
||
/>
|
||
{rows.length === 0 && appliedQuery && (
|
||
<div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60">
|
||
<p className="text-sm text-zinc-500">
|
||
No accounts match <span className="font-medium text-zinc-700">{appliedQuery}</span>.
|
||
{" "}
|
||
<button
|
||
type="button"
|
||
onClick={onSearchClear}
|
||
className="font-medium text-zinc-700 underline-offset-2 hover:underline"
|
||
>
|
||
Clear search
|
||
</button>
|
||
.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{rows.length > 0 && (
|
||
<>
|
||
{/* Desktop / tablet table */}
|
||
<div className="mt-6 hidden overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200/60 sm:block">
|
||
<table className="w-full table-fixed border-collapse">
|
||
<thead>
|
||
<tr className="bg-zinc-50/60">
|
||
<th className="w-[18%] px-5 py-3 text-left">
|
||
<HeaderTh k="username" label="Username" />
|
||
</th>
|
||
<Th>Password</Th>
|
||
<th className="w-[16%] px-5 py-3 text-left">
|
||
<HeaderTh k="status" label="Status" />
|
||
</th>
|
||
<Th>Link</Th>
|
||
<th className="w-20 px-3 py-3" aria-hidden="true" />
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-zinc-100">
|
||
{optimistic.map((row) => {
|
||
const k = (f: string) => `${row.username}::${f}`;
|
||
return (
|
||
<tr key={row.username} className="transition-colors hover:bg-zinc-50/60">
|
||
<td className="px-5 py-3 align-middle font-mono text-[13px] font-semibold text-zinc-900">
|
||
{row.username}
|
||
</td>
|
||
<td className="px-5 py-3 align-middle">
|
||
<EditableCell
|
||
value={row.password}
|
||
label={`password for ${row.username}`}
|
||
isCurrentlyEditing={editingKey === k("password")}
|
||
onEditStart={() => setEditingKey(k("password"))}
|
||
onEditEnd={() => setEditingKey(null)}
|
||
onSave={(v) => saveCell(row.username, "password", v)}
|
||
/>
|
||
</td>
|
||
<td className="px-5 py-3 align-middle">
|
||
<EditableCell
|
||
value={row.status}
|
||
label={`status for ${row.username}`}
|
||
isCurrentlyEditing={editingKey === k("status")}
|
||
onEditStart={() => setEditingKey(k("status"))}
|
||
onEditEnd={() => setEditingKey(null)}
|
||
onSave={(v) => saveCell(row.username, "status", v)}
|
||
renderView={(v) => <StatusBadge status={v} />}
|
||
/>
|
||
</td>
|
||
<td className="px-5 py-3 align-middle">
|
||
<EditableCell
|
||
value={row.link}
|
||
label={`link for ${row.username}`}
|
||
isCurrentlyEditing={editingKey === k("link")}
|
||
onEditStart={() => setEditingKey(k("link"))}
|
||
onEditEnd={() => setEditingKey(null)}
|
||
onSave={(v) => saveCell(row.username, "link", v)}
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-3 text-right align-middle">
|
||
<div className="inline-flex items-center gap-1">
|
||
<CopyButton
|
||
label={row.username}
|
||
onClick={() => handleCopy(row)}
|
||
/>
|
||
<DeleteButton
|
||
label={row.username}
|
||
onClick={() => {
|
||
setDeleteError(null);
|
||
setDeleteTarget(row.username);
|
||
}}
|
||
/>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Mobile cards */}
|
||
<div className="mt-6 space-y-3 sm:hidden">
|
||
{optimistic.map((row) => {
|
||
const k = (f: string) => `${row.username}::${f}`;
|
||
return (
|
||
<div key={row.username} className="rounded-2xl bg-white p-5 ring-1 ring-zinc-200/60">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
<span className="shrink-0 font-mono text-base font-semibold text-zinc-900">
|
||
{row.username}
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<EditableCell
|
||
value={row.status}
|
||
label={`status for ${row.username}`}
|
||
isCurrentlyEditing={editingKey === k("status")}
|
||
onEditStart={() => setEditingKey(k("status"))}
|
||
onEditEnd={() => setEditingKey(null)}
|
||
onSave={(v) => saveCell(row.username, "status", v)}
|
||
renderView={(v) => <StatusBadge status={v} />}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<CopyButton
|
||
label={row.username}
|
||
onClick={() => handleCopy(row)}
|
||
/>
|
||
<DeleteButton
|
||
label={row.username}
|
||
onClick={() => {
|
||
setDeleteError(null);
|
||
setDeleteTarget(row.username);
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<dl className="mt-4 space-y-3 border-t border-zinc-100 pt-4">
|
||
<CardRow label="Password">
|
||
<EditableCell
|
||
value={row.password}
|
||
label={`password for ${row.username}`}
|
||
isCurrentlyEditing={editingKey === k("password")}
|
||
onEditStart={() => setEditingKey(k("password"))}
|
||
onEditEnd={() => setEditingKey(null)}
|
||
onSave={(v) => saveCell(row.username, "password", v)}
|
||
/>
|
||
</CardRow>
|
||
<CardRow label="Link">
|
||
<EditableCell
|
||
value={row.link}
|
||
label={`link for ${row.username}`}
|
||
isCurrentlyEditing={editingKey === k("link")}
|
||
onEditStart={() => setEditingKey(k("link"))}
|
||
onEditEnd={() => setEditingKey(null)}
|
||
onSave={(v) => saveCell(row.username, "link", v)}
|
||
/>
|
||
</CardRow>
|
||
</dl>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Sentinel for infinite scroll. Hidden visually unless we're at
|
||
the bottom; the IntersectionObserver triggers loadMore as it
|
||
comes into view. */}
|
||
<div ref={sentinelRef} className="h-10" aria-hidden="true" />
|
||
{loadingMore && (
|
||
<p className="mt-4 text-center text-[11px] font-medium uppercase tracking-wider text-zinc-400">
|
||
Loading more…
|
||
</p>
|
||
)}
|
||
{!hasMore && rows.length > 0 && (
|
||
<p className="mt-6 text-center text-[11px] text-zinc-400">
|
||
End of list — {rows.length} accounts loaded
|
||
</p>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
<CreateAccountDialog
|
||
open={createOpen}
|
||
onClose={() => setCreateOpen(false)}
|
||
onSuccess={async (name) => {
|
||
setToast({ type: "success", message: `Account ${name} created` });
|
||
await refresh();
|
||
}}
|
||
prefixPattern={prefixPattern}
|
||
/>
|
||
|
||
<Toast toast={toast} onDismiss={() => setToast(null)} />
|
||
|
||
<ConfirmDialog
|
||
open={!!deleteTarget}
|
||
onCancel={() => {
|
||
if (!deleting) setDeleteTarget(null);
|
||
}}
|
||
onConfirm={confirmDelete}
|
||
title={`Delete ${deleteTarget ?? ""}?`}
|
||
message={
|
||
<>
|
||
<p>
|
||
Permanently remove{" "}
|
||
<code className="rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-xs text-zinc-700">
|
||
{deleteTarget}
|
||
</code>{" "}
|
||
from the accounts table. This action cannot be undone.
|
||
</p>
|
||
{deleteError && (
|
||
<p className="mt-3 font-mono text-[11px] text-red-600" role="alert">
|
||
{deleteError}
|
||
</p>
|
||
)}
|
||
</>
|
||
}
|
||
confirmLabel="Delete"
|
||
destructive
|
||
pending={deleting}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SearchBar({
|
||
value,
|
||
onChange,
|
||
onSubmit,
|
||
onClear,
|
||
appliedQuery,
|
||
searching,
|
||
}: {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
onSubmit: (e: React.FormEvent) => void;
|
||
onClear: () => void;
|
||
appliedQuery: string;
|
||
searching: boolean;
|
||
}) {
|
||
return (
|
||
<form onSubmit={onSubmit} role="search" className="mt-4 flex flex-wrap items-center gap-2">
|
||
<label className="sr-only" htmlFor="accounts-search">Search by username</label>
|
||
<input
|
||
id="accounts-search"
|
||
type="search"
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
placeholder="Search username (e.g. 13c4511)"
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
className="min-w-0 flex-1 rounded-full bg-white px-4 py-1.5 text-sm text-zinc-900 ring-1 ring-zinc-200/60 placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900"
|
||
/>
|
||
<button
|
||
type="submit"
|
||
disabled={searching}
|
||
className="inline-flex items-center rounded-full bg-zinc-900 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{searching ? "Finding…" : "Find"}
|
||
</button>
|
||
{appliedQuery && (
|
||
<button
|
||
type="button"
|
||
onClick={onClear}
|
||
disabled={searching}
|
||
className="inline-flex items-center rounded-full bg-white px-3 py-1.5 text-xs font-medium text-zinc-700 ring-1 ring-zinc-200/60 transition-colors hover:bg-zinc-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
Clear
|
||
</button>
|
||
)}
|
||
{appliedQuery && (
|
||
<span className="text-[11px] text-zinc-500">
|
||
Filtered by <span className="font-mono text-zinc-700">{appliedQuery}</span>
|
||
</span>
|
||
)}
|
||
</form>
|
||
);
|
||
}
|
||
|
||
function Th({ children, className = "" }: { children: React.ReactNode; className?: string }) {
|
||
return (
|
||
<th
|
||
className={`px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500 ${className}`}
|
||
>
|
||
{children}
|
||
</th>
|
||
);
|
||
}
|
||
|
||
function CardRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="grid grid-cols-[max-content_1fr] items-baseline gap-x-4">
|
||
<dt className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
||
{label}
|
||
</dt>
|
||
<dd className="min-w-0">{children}</dd>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PageHead({
|
||
total,
|
||
loaded,
|
||
onAdd,
|
||
}: {
|
||
total: number;
|
||
loaded: number;
|
||
onAdd: () => void;
|
||
}) {
|
||
// total = COUNT(*) from the API; loaded = how many rows the user has
|
||
// scrolled in so far. Show the partial count only while it differs
|
||
// from the total, so a fully-scrolled list reads cleanly.
|
||
const showLoaded = loaded > 0 && loaded < total;
|
||
return (
|
||
<div>
|
||
<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="text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl">
|
||
Accounts
|
||
<span className="ml-2 align-middle text-base font-medium text-zinc-400">
|
||
{total}
|
||
</span>
|
||
</h1>
|
||
<button
|
||
type="button"
|
||
onClick={onAdd}
|
||
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>
|
||
Add
|
||
</button>
|
||
</div>
|
||
{showLoaded && (
|
||
<p className="mt-1 text-[11px] text-zinc-400">
|
||
Showing {loaded} of {total}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|