Adds × delete button per row in both tables (desktop column + mobile card header). Click → native <dialog> confirm modal with Esc/backdrop-cancel, destructive red button, error inline. Wires deleteAccount/deleteUser Server Actions calling the new api-server routes; revalidatePath refreshes the list on success. EditableCell input switches to text-base (16px) on phone (sm:text-[13px] above 640px), preventing iOS Safari auto-zoom-on-focus that was shifting the layout when the soft keyboard appeared.
393 lines
14 KiB
TypeScript
393 lines
14 KiB
TypeScript
"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";
|
||
|
||
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 [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} />
|
||
<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.</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<PageHead count={optimistic.length} onRefresh={refresh} refreshing={refreshing} />
|
||
|
||
<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>
|
||
|
||
<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,
|
||
}: {
|
||
count: number;
|
||
onRefresh: () => void;
|
||
refreshing: boolean;
|
||
}) {
|
||
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>
|
||
<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>
|
||
</div>
|
||
);
|
||
}
|