cm_bot_v2/web/components/users-table.tsx
yiekheng e3ac94cada feat(web): manual create flow with input dialog for acc and user
api-server gets /create-acc-data and /create-user-data POST routes
that INSERT into the respective tables with required-field validation.
Frontend adds an 'Add' button next to Refresh in each table head;
opens a native <dialog> form with all fields. Inputs use 16px font on
phone (sm:text-[13px] desktop) so iOS doesn't auto-zoom.

A small form-dialog-shell helper centralizes the modal chrome,
field label, and input class so create-account-dialog and
create-user-dialog stay focused on their fields and validation.
2026-05-02 21:19:24 +08:00

422 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useMemo, useOptimistic, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import type { User } from "@/lib/types";
import { deleteUser, updateUser } from "@/app/actions";
import EditableCell from "./editable-cell";
import ConfirmDialog from "./confirm-dialog";
import CreateUserDialog from "./create-user-dialog";
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<User, "f_password" | "t_username" | "t_password">;
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 <em className="not-italic text-zinc-400"></em>;
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 (
<button
type="button"
onClick={onClick}
aria-label={`Delete ${label}`}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-red-50 hover:text-red-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
>
<span aria-hidden="true" className="text-base leading-none">
×
</span>
</button>
);
}
export default function UsersTable({ initial, prefixPattern }: Props) {
const router = useRouter();
const [sortKey, setSortKey] = useState<SortKey>("last_update_time");
const [sortDir, setSortDir] = useState<SortDir>("desc");
const [editingKey, setEditingKey] = useState<string | null>(null);
const [, startTransition] = useTransition();
const [refreshing, setRefreshing] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>(
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) 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);
resolve(result.ok ? { ok: true } : { 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");
}
}
async function confirmDelete() {
if (!deleteTarget) return;
setDeleting(true);
setDeleteError(null);
const result = await deleteUser(deleteTarget);
setDeleting(false);
if (result.ok) {
setDeleteTarget(null);
} else {
setDeleteError(result.error);
}
}
function HeaderTh({ k, label }: { k: SortKey; label: string }) {
const active = sortKey === k;
return (
<button
type="button"
onClick={() => toggleSort(k)}
className={`inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider transition-colors ${
active ? "text-zinc-900" : "text-zinc-500 hover:text-zinc-900"
}`}
>
{label}
<span aria-hidden="true">{active ? (sortDir === "asc" ? "↑" : "↓") : "↕"}</span>
</button>
);
}
if (initial.length === 0) {
return (
<div>
<PageHead
count={0}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
/>
<div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60">
<p className="text-sm text-zinc-500">
No users yet. Click <span className="font-medium text-zinc-700">Add</span> to create one manually.
</p>
</div>
<CreateUserDialog open={createOpen} onClose={() => setCreateOpen(false)} />
</div>
);
}
return (
<div>
<PageHead
count={optimistic.length}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
/>
<div className="mt-6 hidden overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200/60 sm:block">
<table className="w-full table-fixed border-collapse">
<thead>
<tr className="bg-zinc-50/60">
<th className="w-[18%] px-5 py-3 text-left">
<HeaderTh k="f_username" label="From username" />
</th>
<th className="w-[18%] px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500">
From password
</th>
<th className="w-[18%] px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500">
To username
</th>
<th className="w-[18%] px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500">
To password
</th>
<th className="px-5 py-3 text-left">
<HeaderTh k="last_update_time" label="Last update" />
</th>
<th className="w-12 px-3 py-3" aria-hidden="true" />
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{sorted.map((row) => {
const k = (f: string) => `${row.f_username}::${f}`;
return (
<tr key={row.f_username} className="transition-colors hover:bg-zinc-50/60">
<td className="px-5 py-3 align-middle font-mono text-[13px] font-semibold text-zinc-900">
{row.f_username}
</td>
<td className="px-5 py-3 align-middle">
<EditableCell
value={row.f_password}
label={`from password for ${row.f_username}`}
isCurrentlyEditing={editingKey === k("f_password")}
onEditStart={() => setEditingKey(k("f_password"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.f_username, "f_password", v)}
/>
</td>
<td className="px-5 py-3 align-middle">
<EditableCell
value={row.t_username}
label={`to username for ${row.f_username}`}
isCurrentlyEditing={editingKey === k("t_username")}
onEditStart={() => setEditingKey(k("t_username"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.f_username, "t_username", v)}
/>
</td>
<td className="px-5 py-3 align-middle">
<EditableCell
value={row.t_password}
label={`to password for ${row.f_username}`}
isCurrentlyEditing={editingKey === k("t_password")}
onEditStart={() => setEditingKey(k("t_password"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.f_username, "t_password", v)}
/>
</td>
<td className="px-5 py-3 align-middle text-xs text-zinc-500">
{formatTime(row.last_update_time)}
</td>
<td className="px-3 py-3 text-right align-middle">
<DeleteButton
label={row.f_username}
onClick={() => {
setDeleteError(null);
setDeleteTarget(row.f_username);
}}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-6 space-y-3 sm:hidden">
{sorted.map((row) => {
const k = (f: string) => `${row.f_username}::${f}`;
return (
<div key={row.f_username} className="rounded-2xl bg-white p-5 ring-1 ring-zinc-200/60">
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-base font-semibold text-zinc-900">
{row.f_username}
</span>
<div className="flex items-center gap-2">
<span className="text-[11px] text-zinc-500">
{formatTime(row.last_update_time)}
</span>
<DeleteButton
label={row.f_username}
onClick={() => {
setDeleteError(null);
setDeleteTarget(row.f_username);
}}
/>
</div>
</div>
<dl className="mt-4 space-y-3 border-t border-zinc-100 pt-4">
<MobileRow label="From PW">
<EditableCell
value={row.f_password}
label={`from password for ${row.f_username}`}
isCurrentlyEditing={editingKey === k("f_password")}
onEditStart={() => setEditingKey(k("f_password"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.f_username, "f_password", v)}
/>
</MobileRow>
<MobileRow label="To User">
<EditableCell
value={row.t_username}
label={`to username for ${row.f_username}`}
isCurrentlyEditing={editingKey === k("t_username")}
onEditStart={() => setEditingKey(k("t_username"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.f_username, "t_username", v)}
/>
</MobileRow>
<MobileRow label="To PW">
<EditableCell
value={row.t_password}
label={`to password for ${row.f_username}`}
isCurrentlyEditing={editingKey === k("t_password")}
onEditStart={() => setEditingKey(k("t_password"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.f_username, "t_password", v)}
/>
</MobileRow>
</dl>
</div>
);
})}
</div>
<CreateUserDialog open={createOpen} onClose={() => setCreateOpen(false)} />
<ConfirmDialog
open={!!deleteTarget}
onCancel={() => {
if (!deleting) setDeleteTarget(null);
}}
onConfirm={confirmDelete}
title={`Delete ${deleteTarget ?? ""}?`}
message={
<>
<p>
Permanently remove the user pairing for{" "}
<code className="rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-xs text-zinc-700">
{deleteTarget}
</code>{" "}
from the users table. This action cannot be undone and only
removes the pairing the underlying account row stays.
</p>
{deleteError && (
<p className="mt-3 font-mono text-[11px] text-red-600" role="alert">
{deleteError}
</p>
)}
</>
}
confirmLabel="Delete"
destructive
pending={deleting}
/>
</div>
);
}
function MobileRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[max-content_1fr] items-baseline gap-x-4">
<dt className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">{label}</dt>
<dd className="min-w-0">{children}</dd>
</div>
);
}
function PageHead({
count,
onRefresh,
refreshing,
onAdd,
}: {
count: number;
onRefresh: () => void;
refreshing: boolean;
onAdd: () => void;
}) {
return (
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Table</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl">
Users
<span className="ml-2 align-middle text-base font-medium text-zinc-400">{count}</span>
</h1>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onRefresh}
disabled={refreshing}
className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-medium text-zinc-700 shadow-sm ring-1 ring-zinc-200 transition-colors hover:bg-zinc-50 hover:text-zinc-900 disabled:opacity-60"
>
<span aria-hidden="true" className={refreshing ? "inline-block animate-spin" : ""}>
</span>
Refresh
</button>
<button
type="button"
onClick={onAdd}
className="inline-flex items-center gap-1.5 rounded-full bg-zinc-900 px-4 py-2 text-xs font-medium text-white shadow-sm transition-colors hover:bg-zinc-700"
>
<span aria-hidden="true">+</span>
Add
</button>
</div>
</div>
);
}