"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"; type Props = { initial: Acc[]; initialHasMore: boolean; initialTotal: number; prefixPattern: string; }; type SortDir = "asc" | "desc"; type OptimisticPatch = { username: string; field: keyof Acc; value: string }; function StatusBadge({ status }: { status: string }) { const map: Record = { "": { 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 ( {v.label} ); } function DeleteButton({ label, onClick, }: { label: string; onClick: () => void; }) { return ( ); } export default function AccountsTable({ initial, initialHasMore, initialTotal, prefixPattern, }: Props) { 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); // Accumulated rows from initial server-side fetch + every loadMore. const [rows, setRows] = useState(initial); const [hasMore, setHasMore] = useState(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(initialTotal); const sentinelRef = useRef(null); const [optimistic, applyOptimistic] = useOptimistic( 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 }); } }); }); } async function refresh() { setRefreshing(true); try { const page = await refreshAccounts({ prefix: prefixPattern, dir: sortDir }); setRows(page.rows); setHasMore(page.hasMore); setTotal(page.total); } finally { setRefreshing(false); } } async function changeSort(next: SortDir) { if (next === sortDir) return; setSortDir(next); setLoadingMore(true); try { const page = await loadMoreAccounts({ offset: 0, prefix: prefixPattern, dir: next, }); 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 || refreshing) 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, dir: sortDir, }) .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, refreshing, rows.length, prefixPattern, sortDir]); 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); } } if (rows.length === 0) { return (
setCreateOpen(true)} />

No accounts yet. Click Add above to create one manually, or wait for the monitor.

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} />
); } return (
setCreateOpen(true)} /> {/* Desktop / tablet table */}
{optimistic.map((row) => { const k = (f: string) => `${row.username}::${f}`; return ( ); })}
Password Status Link
{row.username} setEditingKey(k("password"))} onEditEnd={() => setEditingKey(null)} onSave={(v) => saveCell(row.username, "password", v)} /> setEditingKey(k("status"))} onEditEnd={() => setEditingKey(null)} onSave={(v) => saveCell(row.username, "status", v)} renderView={(v) => } /> setEditingKey(k("link"))} onEditEnd={() => setEditingKey(null)} onSave={(v) => saveCell(row.username, "link", v)} /> { setDeleteError(null); setDeleteTarget(row.username); }} />
{/* Mobile cards */}
{optimistic.map((row) => { const k = (f: string) => `${row.username}::${f}`; return (
{row.username}
setEditingKey(k("status"))} onEditEnd={() => setEditingKey(null)} onSave={(v) => saveCell(row.username, "status", v)} renderView={(v) => } />
{ setDeleteError(null); setDeleteTarget(row.username); }} />
setEditingKey(k("password"))} onEditEnd={() => setEditingKey(null)} onSave={(v) => saveCell(row.username, "password", v)} /> setEditingKey(k("link"))} onEditEnd={() => setEditingKey(null)} onSave={(v) => saveCell(row.username, "link", v)} />
); })}
{/* Sentinel for infinite scroll. Hidden visually unless we're at the bottom; the IntersectionObserver triggers loadMore as it comes into view. */} ); } function Th({ children, className = "" }: { children: React.ReactNode; className?: string }) { return ( {children} ); } function CardRow({ 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; }) { // 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 (

Table

Accounts {total}

{showLoaded && (

Showing {loaded} of {total} — keep scrolling to load more

)}
); }