diff --git a/web/components/accounts-table.tsx b/web/components/accounts-table.tsx new file mode 100644 index 0000000..1fa886d --- /dev/null +++ b/web/components/accounts-table.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { useMemo, useOptimistic, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import type { Acc } from "@/lib/types"; +import { updateAccount } from "@/app/actions"; +import EditableCell from "./editable-cell"; + +type Props = { initial: Acc[]; prefixPattern: string }; + +type SortDir = "asc" | "desc"; +type OptimisticPatch = { username: string; field: keyof Acc; value: string }; + +function sortAccounts(rows: Acc[], dir: SortDir, prefix: string): Acc[] { + return [...rows].sort((a, b) => { + const ap = a.username.startsWith(prefix); + const bp = b.username.startsWith(prefix); + if (ap && !bp) return -1; + if (!ap && bp) return 1; + return dir === "asc" + ? a.username.localeCompare(b.username) + : b.username.localeCompare(a.username); + }); +} + +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-800", label: "wait" }, + done: { bg: "bg-emerald-100", fg: "text-emerald-800", label: "done" }, + }; + const v = map[status] ?? { bg: "bg-zinc-100", fg: "text-zinc-600", label: status || "—" }; + return ( + + {v.label} + + ); +} + +export default function AccountsTable({ initial, prefixPattern }: Props) { + const router = useRouter(); + const [sortDir, setSortDir] = useState("desc"); + const [editingKey, setEditingKey] = useState(null); + const [, startTransition] = useTransition(); + const [refreshing, setRefreshing] = useState(false); + + const [optimistic, applyOptimistic] = useOptimistic( + initial, + (state, patch) => + state.map((row) => + row.username === patch.username ? { ...row, [patch.field]: patch.value } : row, + ), + ); + + const sorted = useMemo( + () => sortAccounts(optimistic, sortDir, prefixPattern), + [optimistic, sortDir, prefixPattern], + ); + + 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 = initial.find((r) => r.username === username); + if (!row) { + resolve({ ok: false, error: "row not found" }); + return; + } + const next: Acc = { ...row, [field]: value }; + const result = await updateAccount(next); + if (result.ok) resolve({ ok: true }); + else resolve({ ok: false, error: result.error }); + }); + }); + } + + function refresh() { + setRefreshing(true); + startTransition(() => { + router.refresh(); + setTimeout(() => setRefreshing(false), 400); + }); + } + + if (initial.length === 0) { + return ( +
+

+ Accounts +

+
+

+ No accounts yet. The monitor will create some on the next run. +

+
+
+ ); + } + + return ( +
+
+
+

+ // table +

+

+ Accounts +

+
+
+
+ + count + + + {optimistic.length} + +
+ +
+
+ +
+ + + + + + + + + + + {sorted.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)} + /> +
+
+ setEditingKey(k("link"))} + onEditEnd={() => setEditingKey(null)} + onSave={(v) => saveCell(row.username, "link", v)} + /> +
+
+ +
+ {sorted.map((row) => { + const k = (f: string) => `${row.username}::${f}`; + return ( +
+
+ {row.username} + +
+
+
+ password +
+
+ setEditingKey(k("password"))} + onEditEnd={() => setEditingKey(null)} + onSave={(v) => saveCell(row.username, "password", v)} + /> +
+
+ status +
+
+ setEditingKey(k("status"))} + onEditEnd={() => setEditingKey(null)} + onSave={(v) => saveCell(row.username, "status", v)} + /> +
+
+ link +
+
+ setEditingKey(k("link"))} + onEditEnd={() => setEditingKey(null)} + onSave={(v) => saveCell(row.username, "link", v)} + /> +
+
+
+ ); + })} +
+
+ ); +} diff --git a/web/components/editable-cell.tsx b/web/components/editable-cell.tsx new file mode 100644 index 0000000..b804fc6 --- /dev/null +++ b/web/components/editable-cell.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useEffect, useRef, useState, useTransition } from "react"; + +type EditableCellProps = { + value: string; + onSave: (next: string) => Promise<{ ok: boolean; error?: string }>; + label?: string; + isCurrentlyEditing?: boolean; + onEditStart?: () => void; + onEditEnd?: () => void; +}; + +export default function EditableCell({ + value, + onSave, + label, + isCurrentlyEditing, + onEditStart, + onEditEnd, +}: EditableCellProps) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + const inputRef = useRef(null); + + // Keep draft in sync if the underlying value changes from outside + // (auto-refresh, server revalidation) while we are NOT actively editing. + useEffect(() => { + if (!editing) setDraft(value); + }, [value, editing]); + + // Auto-clear an error after 3 seconds. + useEffect(() => { + if (!error) return; + const id = setTimeout(() => setError(null), 3000); + return () => clearTimeout(id); + }, [error]); + + function begin() { + setDraft(value); + setEditing(true); + onEditStart?.(); + requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + } + + function cancel() { + setEditing(false); + setDraft(value); + setError(null); + onEditEnd?.(); + } + + function commit() { + const next = draft; + if (next === value) { + cancel(); + return; + } + startTransition(async () => { + const result = await onSave(next); + if (result.ok) { + setEditing(false); + setError(null); + onEditEnd?.(); + } else { + setError(result.error ?? "Save failed"); + } + }); + } + + if (!editing) { + return ( + + ); + } + + return ( +
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commit(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancel(); + } + }} + disabled={isPending} + className="w-full min-w-0 rounded-sm border-2 border-yellow-400 bg-white px-1 py-0.5 font-mono text-sm text-zinc-900 outline-none disabled:opacity-60" + /> + + +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/web/components/users-table.tsx b/web/components/users-table.tsx new file mode 100644 index 0000000..920bfef --- /dev/null +++ b/web/components/users-table.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { useMemo, useOptimistic, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import type { User } from "@/lib/types"; +import { updateUser } from "@/app/actions"; +import EditableCell from "./editable-cell"; + +type Props = { initial: User[]; prefixPattern: string }; + +type SortDir = "asc" | "desc"; +type SortKey = "f_username" | "last_update_time"; + +type OptimisticPatch = { + f_username: string; + field: keyof Pick; + value: string; +}; + +function timeOf(t: string | null) { + if (!t) return 0; + const ms = Date.parse(t); + return Number.isNaN(ms) ? 0 : ms; +} + +function sortUsers(rows: User[], key: SortKey, dir: SortDir, prefix: string): User[] { + return [...rows].sort((a, b) => { + const ap = a.f_username.startsWith(prefix); + const bp = b.f_username.startsWith(prefix); + if (ap && !bp) return -1; + if (!ap && bp) return 1; + if (key === "f_username") { + return dir === "asc" + ? a.f_username.localeCompare(b.f_username) + : b.f_username.localeCompare(a.f_username); + } + return dir === "asc" + ? timeOf(a.last_update_time) - timeOf(b.last_update_time) + : timeOf(b.last_update_time) - timeOf(a.last_update_time); + }); +} + +function formatTime(t: string | null) { + if (!t) return ; + const d = new Date(t); + if (Number.isNaN(d.getTime())) return t; + return d.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +export default function UsersTable({ initial, prefixPattern }: Props) { + const router = useRouter(); + 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 [optimistic, applyOptimistic] = useOptimistic( + initial, + (state, patch) => + state.map((row) => + row.f_username === patch.f_username ? { ...row, [patch.field]: patch.value } : row, + ), + ); + + const sorted = useMemo( + () => sortUsers(optimistic, sortKey, sortDir, prefixPattern), + [optimistic, sortKey, sortDir, prefixPattern], + ); + + 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 = initial.find((r) => r.f_username === f_username); + if (!row) { + resolve({ ok: false, error: "row not found" }); + return; + } + 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) resolve({ ok: true }); + else resolve({ ok: false, error: result.error }); + }); + }); + } + + function refresh() { + setRefreshing(true); + startTransition(() => { + router.refresh(); + setTimeout(() => setRefreshing(false), 400); + }); + } + + function toggleSort(k: SortKey) { + if (sortKey === k) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortKey(k); + setSortDir("desc"); + } + } + + if (initial.length === 0) { + return ( +
+

+ Users +

+
+

No users yet.

+
+
+ ); + } + + function HeaderButton({ k, label }: { k: SortKey; label: string }) { + const active = sortKey === k; + return ( + + ); + } + + return ( +
+
+
+

+ // table +

+

+ Users +

+
+
+
+ + count + + {optimistic.length} +
+ +
+
+ +
+ + + + + + + + + + + + {sorted.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)} +
+
+ +
+ {sorted.map((row) => { + const k = (f: string) => `${row.f_username}::${f}`; + return ( +
+
+ + {row.f_username} + + + {formatTime(row.last_update_time)} + +
+
+
+ from pw +
+
+ setEditingKey(k("f_password"))} + onEditEnd={() => setEditingKey(null)} + onSave={(v) => saveCell(row.f_username, "f_password", v)} + /> +
+
+ to user +
+
+ setEditingKey(k("t_username"))} + onEditEnd={() => setEditingKey(null)} + onSave={(v) => saveCell(row.f_username, "t_username", v)} + /> +
+
+ to pw +
+
+ setEditingKey(k("t_password"))} + onEditEnd={() => setEditingKey(null)} + onSave={(v) => saveCell(row.f_username, "t_password", v)} + /> +
+
+
+ ); + })} +
+
+ ); +}