"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"; type Props = { initial: User[]; initialHasMore: boolean; initialTotal: number; prefixPattern: string; }; type SortDir = "asc" | "desc"; type SortKey = "f_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 ( ); } 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 [refreshing, setRefreshing] = useState(false); 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); const [rows, setRows] = useState(initial); const [hasMore, setHasMore] = useState(initialHasMore); const [total, setTotal] = useState(initialTotal); const sentinelRef = useRef(null); 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 }); } }); }); } async function refresh() { setRefreshing(true); try { const page = await refreshUsers({ prefix: prefixPattern, sort: sortKey, dir: sortDir, }); setRows(page.rows); setHasMore(page.hasMore); setTotal(page.total); } finally { setRefreshing(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, }); setRows(page.rows); setHasMore(page.hasMore); setTotal(page.total); } finally { setLoadingMore(false); } } useEffect(() => { if (!hasMore || loadingMore || refreshing) 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, }) .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, refreshing, rows.length, prefixPattern, sortKey, sortDir]); 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 ( ); } if (rows.length === 0) { 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)} />
{optimistic.map((row) => { const k = (f: string) => `${row.f_username}::${f}`; return ( ); })}
From password To username 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)} { 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)} { 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 MobileRow({ label, children }: { label: string; children: React.ReactNode }) { return (
{label}
{children}
); } function PageHead({ total, loaded, onRefresh, refreshing, onAdd, }: { total: number; loaded: number; onRefresh: () => void; refreshing: boolean; onAdd: () => void; }) { const showLoaded = loaded > 0 && loaded < total; return (

Table

Users {total}

{showLoaded && (

Showing {loaded} of {total}

)}
); }