"use client"; import { useEffect, useOptimistic, useRef, useState, useTransition } from "react"; import type { User } from "@/lib/types"; import { deleteUser, loadMoreUsers, refreshUsers, updateUser, } from "@/app/actions"; import EditableCell from "./editable-cell"; import ConfirmDialog from "./confirm-dialog"; import CreateUserDialog from "./create-user-dialog"; import Toast, { type ToastMessage } from "./toast"; import { copyToClipboard } from "@/lib/clipboard"; type Props = { initial: User[]; initialHasMore: boolean; initialTotal: number; prefixPattern: string; }; type SortDir = "asc" | "desc"; type SortKey = "f_username" | "t_username" | "last_update_time"; type OptimisticPatch = { f_username: string; field: keyof Pick; value: string; }; function formatTime(t: string | null) { if (!t) return ; const d = new Date(t); if (Number.isNaN(d.getTime())) return t; return d.toLocaleString(undefined, { month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit", }); } function DeleteButton({ label, onClick, }: { label: string; onClick: () => void; }) { return ( ); } function CopyButton({ label, onClick, }: { label: string; onClick: () => void; }) { return ( ); } export default function UsersTable({ initial, initialHasMore, initialTotal, prefixPattern, }: Props) { const [sortKey, setSortKey] = useState("last_update_time"); const [sortDir, setSortDir] = useState("desc"); const [editingKey, setEditingKey] = useState(null); const [, startTransition] = useTransition(); const [loadingMore, setLoadingMore] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const [deleteError, setDeleteError] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [toast, setToast] = useState(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(initial); const [hasMore, setHasMore] = useState(initialHasMore); const [total, setTotal] = useState(initialTotal); const sentinelRef = useRef(null); // `searchInput` is what the user is typing; `appliedQuery` is what // the server actually filtered on. Decoupling them means the input // doesn't fire a request on every 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( rows, (state, patch) => state.map((row) => row.f_username === patch.f_username ? { ...row, [patch.field]: patch.value } : row, ), ); function saveCell( f_username: string, field: OptimisticPatch["field"], value: string, ) { return new Promise<{ ok: boolean; error?: string }>((resolve) => { startTransition(async () => { applyOptimistic({ f_username, field, value }); const row = rows.find((r) => r.f_username === f_username); if (!row) return resolve({ ok: false, error: "row not found" }); const next = { f_username: row.f_username, f_password: row.f_password, t_username: row.t_username, t_password: row.t_password, [field]: value, }; const result = await updateUser(next); if (result.ok) { setRows((prev) => prev.map((r) => r.f_username === f_username ? { ...r, [field]: value } : 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. async function refresh() { const page = await refreshUsers({ 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 refreshUsers({ 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 loadMoreUsers({ offset: 0, prefix: prefixPattern, sort: nextKey, dir: nextDir, q: appliedQuery, }); setRows(page.rows); setHasMore(page.hasMore); setTotal(page.total); } finally { setLoadingMore(false); } } 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); loadMoreUsers({ 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("loadMoreUsers 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: User) { const text = `From Username: ${row.f_username}\n` + `From Password: ${row.f_password}\n` + `To Username: ${row.t_username}\n` + `To Password: ${row.t_password}`; const ok = await copyToClipboard(text); setToast( ok ? { type: "success", message: `Copied credentials for ${row.f_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 deleteUser(deleteTarget); setDeleting(false); if (result.ok) { const deleted = deleteTarget; setRows((prev) => prev.filter((r) => r.f_username !== deleted)); setTotal((t) => Math.max(0, t - 1)); setDeleteTarget(null); setToast({ type: "success", message: `User ${deleted} deleted` }); } else { setDeleteError(result.error); } } function HeaderTh({ k, label }: { k: SortKey; label: string }) { const active = sortKey === k; return ( ); } const onSearchSubmit = (e: React.FormEvent) => { e.preventDefault(); void applySearch(searchInput.trim()); }; const onSearchClear = () => { setSearchInput(""); void applySearch(""); }; if (rows.length === 0 && !appliedQuery) { return (
setCreateOpen(true)} />

No users yet. Click Add to create one manually.

setCreateOpen(false)} onSuccess={async (name) => { setToast({ type: "success", message: `User ${name} created` }); await refresh(); }} /> setToast(null)} />
); } return (
setCreateOpen(true)} /> {rows.length === 0 && appliedQuery && (

No users match {appliedQuery}. {" "} .

)} {rows.length > 0 && ( <>
{optimistic.map((row) => { const k = (f: string) => `${row.f_username}::${f}`; return ( ); })}
From password To password
{row.f_username} setEditingKey(k("f_password"))} onEditEnd={() => setEditingKey(null)} onSave={(v) => saveCell(row.f_username, "f_password", v)} /> setEditingKey(k("t_username"))} onEditEnd={() => setEditingKey(null)} onSave={(v) => saveCell(row.f_username, "t_username", v)} /> setEditingKey(k("t_password"))} onEditEnd={() => setEditingKey(null)} onSave={(v) => saveCell(row.f_username, "t_password", v)} /> {formatTime(row.last_update_time)}
handleCopy(row)} /> { setDeleteError(null); setDeleteTarget(row.f_username); }} />
{optimistic.map((row) => { const k = (f: string) => `${row.f_username}::${f}`; return (
{row.f_username}
{formatTime(row.last_update_time)} handleCopy(row)} /> { setDeleteError(null); setDeleteTarget(row.f_username); }} />
setEditingKey(k("f_password"))} onEditEnd={() => setEditingKey(null)} onSave={(v) => saveCell(row.f_username, "f_password", v)} /> setEditingKey(k("t_username"))} onEditEnd={() => setEditingKey(null)} onSave={(v) => saveCell(row.f_username, "t_username", v)} /> setEditingKey(k("t_password"))} onEditEnd={() => setEditingKey(null)} onSave={(v) => saveCell(row.f_username, "t_password", v)} />
); })}
); } 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 (
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" /> {appliedQuery && ( )} {appliedQuery && ( Filtered by {appliedQuery} )}
); } function MobileRow({ label, children }: { label: string; children: React.ReactNode }) { return (
{label}
{children}
); } function PageHead({ total, loaded, onAdd, }: { total: number; loaded: number; onAdd: () => void; }) { const showLoaded = loaded > 0 && loaded < total; return (

Table

Users {total}

{showLoaded && (

Showing {loaded} of {total}

)}
); }