"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 = { "": { 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 ( ); } function CopyButton({ label, onClick, }: { label: string; onClick: () => void; }) { return ( ); } export default function AccountsTable({ initial, initialHasMore, initialTotal, prefixPattern, }: Props) { const [sortKey, setSortKey] = useState("username"); 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); // 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(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); // `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( 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 ( ); } if (rows.length === 0 && !appliedQuery) { 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)} /> {rows.length === 0 && appliedQuery && (

No accounts match {appliedQuery}. {" "} .

)} {rows.length > 0 && ( <> {/* Desktop / tablet table */}
{optimistic.map((row) => { const k = (f: string) => `${row.username}::${f}`; return ( ); })}
Password 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)} />
handleCopy(row)} /> { 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) => } />
handleCopy(row)} /> { 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 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 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, 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 (

Table

Accounts {total}

{showLoaded && (

Showing {loaded} of {total}

)}
); }