feat(web): delete with confirm dialog + fix iOS auto-zoom on edit

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.
This commit is contained in:
yiekheng 2026-05-02 21:17:19 +08:00
parent dac1e10b5d
commit e507714dc5
5 changed files with 483 additions and 209 deletions

View File

@ -25,3 +25,23 @@ export async function updateUser(data: UserUpdate): Promise<ActionResult> {
return { ok: false, error: err instanceof Error ? err.message : String(err) }; return { ok: false, error: err instanceof Error ? err.message : String(err) };
} }
} }
export async function deleteAccount(username: string): Promise<ActionResult> {
try {
await fetchApi("/delete-acc-data", { method: "POST", body: { username } });
revalidatePath("/");
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function deleteUser(f_username: string): Promise<ActionResult> {
try {
await fetchApi("/delete-user-data", { method: "POST", body: { f_username } });
revalidatePath("/users");
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}

View File

@ -3,11 +3,11 @@
import { useMemo, useOptimistic, useState, useTransition } from "react"; import { useMemo, useOptimistic, useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { Acc } from "@/lib/types"; import type { Acc } from "@/lib/types";
import { updateAccount } from "@/app/actions"; import { deleteAccount, updateAccount } from "@/app/actions";
import EditableCell from "./editable-cell"; import EditableCell from "./editable-cell";
import ConfirmDialog from "./confirm-dialog";
type Props = { initial: Acc[]; prefixPattern: string }; type Props = { initial: Acc[]; prefixPattern: string };
type SortDir = "asc" | "desc"; type SortDir = "asc" | "desc";
type OptimisticPatch = { username: string; field: keyof Acc; value: string }; type OptimisticPatch = { username: string; field: keyof Acc; value: string };
@ -26,25 +26,49 @@ function sortAccounts(rows: Acc[], dir: SortDir, prefix: string): Acc[] {
function StatusBadge({ status }: { status: string }) { function StatusBadge({ status }: { status: string }) {
const map: Record<string, { bg: string; fg: string; label: string }> = { const map: Record<string, { bg: string; fg: string; label: string }> = {
"": { bg: "bg-zinc-100", fg: "text-zinc-600", label: "available" }, "": { bg: "bg-zinc-100", fg: "text-zinc-600", label: "available" },
wait: { bg: "bg-amber-100", fg: "text-amber-800", label: "wait" }, wait: { bg: "bg-amber-100", fg: "text-amber-700", label: "wait" },
done: { bg: "bg-emerald-100", fg: "text-emerald-800", label: "done" }, 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 || "—" }; const v = map[status] ?? { bg: "bg-zinc-100", fg: "text-zinc-600", label: status || "—" };
return ( return (
<span <span
className={`inline-flex items-center rounded-sm border border-current/10 ${v.bg} ${v.fg} px-1.5 py-0.5 font-mono text-[11px] font-semibold uppercase tracking-widest`} className={`inline-flex items-center rounded-full ${v.bg} ${v.fg} px-2 py-0.5 text-[11px] font-medium`}
> >
{v.label} {v.label}
</span> </span>
); );
} }
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 AccountsTable({ initial, prefixPattern }: Props) { export default function AccountsTable({ initial, prefixPattern }: Props) {
const router = useRouter(); const router = useRouter();
const [sortDir, setSortDir] = useState<SortDir>("desc"); const [sortDir, setSortDir] = useState<SortDir>("desc");
const [editingKey, setEditingKey] = useState<string | null>(null); const [editingKey, setEditingKey] = useState<string | null>(null);
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [refreshing, setRefreshing] = useState(false); 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<Acc[], OptimisticPatch>( const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>(
initial, initial,
@ -64,14 +88,10 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
startTransition(async () => { startTransition(async () => {
applyOptimistic({ username, field, value }); applyOptimistic({ username, field, value });
const row = initial.find((r) => r.username === username); const row = initial.find((r) => r.username === username);
if (!row) { if (!row) return resolve({ ok: false, error: "row not found" });
resolve({ ok: false, error: "row not found" });
return;
}
const next: Acc = { ...row, [field]: value }; const next: Acc = { ...row, [field]: value };
const result = await updateAccount(next); const result = await updateAccount(next);
if (result.ok) resolve({ ok: true }); resolve(result.ok ? { ok: true } : { ok: false, error: result.error });
else resolve({ ok: false, error: result.error });
}); });
}); });
} }
@ -84,14 +104,25 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
}); });
} }
async function confirmDelete() {
if (!deleteTarget) return;
setDeleting(true);
setDeleteError(null);
const result = await deleteAccount(deleteTarget);
setDeleting(false);
if (result.ok) {
setDeleteTarget(null);
} else {
setDeleteError(result.error);
}
}
if (initial.length === 0) { if (initial.length === 0) {
return ( return (
<div className="mx-auto max-w-3xl px-4 py-16"> <div>
<h1 className="font-mono text-2xl font-bold uppercase tracking-tight text-zinc-900"> <PageHead count={0} onRefresh={refresh} refreshing={refreshing} />
Accounts <div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60">
</h1> <p className="text-sm text-zinc-500">
<div className="mt-8 border-2 border-dashed border-zinc-300 bg-white p-10 text-center">
<p className="font-mono text-sm text-zinc-500">
No accounts yet. The monitor will create some on the next run. No accounts yet. The monitor will create some on the next run.
</p> </p>
</div> </div>
@ -100,76 +131,39 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
} }
return ( return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:py-12"> <div>
<div className="flex flex-wrap items-end justify-between gap-4"> <PageHead count={optimistic.length} onRefresh={refresh} refreshing={refreshing} />
<div>
<p className="font-mono text-[11px] font-bold uppercase tracking-[0.25em] text-zinc-500">
// table
</p>
<h1 className="mt-1 font-mono text-2xl font-bold uppercase tracking-tight text-zinc-900 sm:text-3xl">
Accounts
</h1>
</div>
<div className="flex items-center gap-3">
<div className="inline-flex items-baseline gap-2 border-2 border-zinc-900 bg-white px-3 py-1.5">
<span className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500">
count
</span>
<span className="font-mono text-lg font-bold text-zinc-900">
{optimistic.length}
</span>
</div>
<button
type="button"
onClick={refresh}
disabled={refreshing}
className="inline-flex items-center gap-1.5 border-2 border-zinc-900 bg-yellow-300 px-3 py-1.5 font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-zinc-900 hover:bg-zinc-900 hover:text-yellow-300 disabled:opacity-60"
>
<span aria-hidden="true" className={refreshing ? "inline-block animate-spin" : ""}>
</span>
refresh
</button>
</div>
</div>
<div className="mt-6 hidden border-2 border-zinc-900 bg-white sm:block"> {/* Desktop / tablet table */}
<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"> <table className="w-full table-fixed border-collapse">
<thead> <thead>
<tr className="border-b-2 border-zinc-900 bg-zinc-50"> <tr className="bg-zinc-50/60">
<th className="w-1/5 px-3 py-2 text-left"> <th className="w-[18%] px-5 py-3 text-left">
<button <button
type="button" type="button"
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))} onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
className="inline-flex items-center gap-1 font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-700 hover:text-zinc-900" className="inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider text-zinc-500 transition-colors hover:text-zinc-900"
> >
username Username
<span aria-hidden="true">{sortDir === "asc" ? "↑" : "↓"}</span> <span aria-hidden="true">{sortDir === "asc" ? "↑" : "↓"}</span>
</button> </button>
</th> </th>
<th className="w-1/5 px-3 py-2 text-left font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-700"> <Th>Password</Th>
password <Th className="w-[16%]">Status</Th>
</th> <Th>Link</Th>
<th className="w-[15%] px-3 py-2 text-left font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-700"> <th className="w-12 px-3 py-3" aria-hidden="true" />
status
</th>
<th className="px-3 py-2 text-left font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-700">
link
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody className="divide-y divide-zinc-100">
{sorted.map((row) => { {sorted.map((row) => {
const k = (f: string) => `${row.username}::${f}`; const k = (f: string) => `${row.username}::${f}`;
return ( return (
<tr <tr key={row.username} className="transition-colors hover:bg-zinc-50/60">
key={row.username} <td className="px-5 py-3 align-middle font-mono text-[13px] font-semibold text-zinc-900">
className="border-b border-zinc-200 last:border-b-0 hover:bg-zinc-50"
>
<td className="px-3 py-2 align-middle font-mono text-sm font-semibold text-zinc-900">
{row.username} {row.username}
</td> </td>
<td className="px-3 py-2 align-middle"> <td className="px-5 py-3 align-middle">
<EditableCell <EditableCell
value={row.password} value={row.password}
label={`password for ${row.username}`} label={`password for ${row.username}`}
@ -179,7 +173,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
onSave={(v) => saveCell(row.username, "password", v)} onSave={(v) => saveCell(row.username, "password", v)}
/> />
</td> </td>
<td className="px-3 py-2 align-middle"> <td className="px-5 py-3 align-middle">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StatusBadge status={row.status} /> <StatusBadge status={row.status} />
<EditableCell <EditableCell
@ -192,7 +186,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
/> />
</div> </div>
</td> </td>
<td className="px-3 py-2 align-middle"> <td className="px-5 py-3 align-middle">
<EditableCell <EditableCell
value={row.link} value={row.link}
label={`link for ${row.username}`} label={`link for ${row.username}`}
@ -202,6 +196,15 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
onSave={(v) => saveCell(row.username, "link", v)} onSave={(v) => saveCell(row.username, "link", v)}
/> />
</td> </td>
<td className="px-3 py-3 text-right align-middle">
<DeleteButton
label={row.username}
onClick={() => {
setDeleteError(null);
setDeleteTarget(row.username);
}}
/>
</td>
</tr> </tr>
); );
})} })}
@ -209,20 +212,29 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
</table> </table>
</div> </div>
{/* Mobile cards */}
<div className="mt-6 space-y-3 sm:hidden"> <div className="mt-6 space-y-3 sm:hidden">
{sorted.map((row) => { {sorted.map((row) => {
const k = (f: string) => `${row.username}::${f}`; const k = (f: string) => `${row.username}::${f}`;
return ( return (
<div key={row.username} className="border-2 border-zinc-900 bg-white p-3"> <div key={row.username} className="rounded-2xl bg-white p-5 ring-1 ring-zinc-200/60">
<div className="flex items-baseline justify-between"> <div className="flex items-center justify-between gap-2">
<span className="font-mono text-base font-bold text-zinc-900">{row.username}</span> <span className="font-mono text-base font-semibold text-zinc-900">
<StatusBadge status={row.status} /> {row.username}
</span>
<div className="flex items-center gap-2">
<StatusBadge status={row.status} />
<DeleteButton
label={row.username}
onClick={() => {
setDeleteError(null);
setDeleteTarget(row.username);
}}
/>
</div>
</div> </div>
<dl className="mt-3 grid grid-cols-[max-content_1fr] items-baseline gap-x-3 gap-y-2 border-t border-zinc-200 pt-3"> <dl className="mt-4 space-y-3 border-t border-zinc-100 pt-4">
<dt className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500"> <CardRow label="Password">
password
</dt>
<dd>
<EditableCell <EditableCell
value={row.password} value={row.password}
label={`password for ${row.username}`} label={`password for ${row.username}`}
@ -231,11 +243,8 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
onEditEnd={() => setEditingKey(null)} onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "password", v)} onSave={(v) => saveCell(row.username, "password", v)}
/> />
</dd> </CardRow>
<dt className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500"> <CardRow label="Status">
status
</dt>
<dd>
<EditableCell <EditableCell
value={row.status} value={row.status}
label={`status for ${row.username}`} label={`status for ${row.username}`}
@ -244,11 +253,8 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
onEditEnd={() => setEditingKey(null)} onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "status", v)} onSave={(v) => saveCell(row.username, "status", v)}
/> />
</dd> </CardRow>
<dt className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500"> <CardRow label="Link">
link
</dt>
<dd>
<EditableCell <EditableCell
value={row.link} value={row.link}
label={`link for ${row.username}`} label={`link for ${row.username}`}
@ -257,12 +263,98 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
onEditEnd={() => setEditingKey(null)} onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "link", v)} onSave={(v) => saveCell(row.username, "link", v)}
/> />
</dd> </CardRow>
</dl> </dl>
</div> </div>
); );
})} })}
</div> </div>
<ConfirmDialog
open={!!deleteTarget}
onCancel={() => {
if (!deleting) setDeleteTarget(null);
}}
onConfirm={confirmDelete}
title={`Delete ${deleteTarget ?? ""}?`}
message={
<>
<p>
Permanently remove{" "}
<code className="rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-xs text-zinc-700">
{deleteTarget}
</code>{" "}
from the accounts table. This action cannot be undone.
</p>
{deleteError && (
<p className="mt-3 font-mono text-[11px] text-red-600" role="alert">
{deleteError}
</p>
)}
</>
}
confirmLabel="Delete"
destructive
pending={deleting}
/>
</div>
);
}
function Th({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return (
<th
className={`px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500 ${className}`}
>
{children}
</th>
);
}
function CardRow({ 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">
Accounts
<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> </div>
); );
} }

View File

@ -0,0 +1,88 @@
"use client";
import { useEffect, useRef } from "react";
type Props = {
open: boolean;
onCancel: () => void;
onConfirm: () => void;
title: string;
message: React.ReactNode;
confirmLabel?: string;
destructive?: boolean;
pending?: boolean;
};
/**
* Centered modal confirmation dialog. Uses the native <dialog> element
* so we get Esc-to-close, focus trapping, and the ::backdrop pseudo for
* the scrim no a11y tax we'd pay rolling our own. Backdrop click
* cancels.
*/
export default function ConfirmDialog({
open,
onCancel,
onConfirm,
title,
message,
confirmLabel = "Confirm",
destructive = false,
pending = false,
}: Props) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open && !dialog.open) {
dialog.showModal();
} else if (!open && dialog.open) {
dialog.close();
}
}, [open]);
return (
<dialog
ref={ref}
onClose={onCancel}
onClick={(e) => {
// Click on the dialog background (not the inner form) cancels.
if (e.target === ref.current) onCancel();
}}
className="m-auto w-[min(92vw,440px)] rounded-2xl bg-white p-0 ring-1 ring-zinc-200/60 backdrop:bg-zinc-900/50 backdrop:backdrop-blur-sm"
>
<form
method="dialog"
onSubmit={(e) => {
e.preventDefault();
onConfirm();
}}
className="flex flex-col gap-4 p-6"
>
<h2 className="text-lg font-semibold tracking-tight text-zinc-900">
{title}
</h2>
<div className="text-sm leading-relaxed text-zinc-600">{message}</div>
<div className="mt-2 flex items-center justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={pending}
className="rounded-full px-4 py-2 text-xs font-medium text-zinc-700 transition-colors hover:bg-zinc-100 hover:text-zinc-900 disabled:opacity-60"
>
Cancel
</button>
<button
type="submit"
disabled={pending}
className={`rounded-full px-4 py-2 text-xs font-medium text-white transition-colors disabled:opacity-60 ${
destructive ? "bg-red-600 hover:bg-red-700" : "bg-zinc-900 hover:bg-zinc-700"
}`}
>
{pending ? "…" : confirmLabel}
</button>
</div>
</form>
</dialog>
);
}

View File

@ -25,13 +25,10 @@ export default function EditableCell({
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Keep draft in sync if the underlying value changes from outside
// (auto-refresh, server revalidation) while we are NOT actively editing.
useEffect(() => { useEffect(() => {
if (!editing) setDraft(value); if (!editing) setDraft(value);
}, [value, editing]); }, [value, editing]);
// Auto-clear an error after 3 seconds.
useEffect(() => { useEffect(() => {
if (!error) return; if (!error) return;
const id = setTimeout(() => setError(null), 3000); const id = setTimeout(() => setError(null), 3000);
@ -56,13 +53,12 @@ export default function EditableCell({
} }
function commit() { function commit() {
const next = draft; if (draft === value) {
if (next === value) {
cancel(); cancel();
return; return;
} }
startTransition(async () => { startTransition(async () => {
const result = await onSave(next); const result = await onSave(draft);
if (result.ok) { if (result.ok) {
setEditing(false); setEditing(false);
setError(null); setError(null);
@ -79,14 +75,14 @@ export default function EditableCell({
type="button" type="button"
onClick={begin} onClick={begin}
aria-label={label ? `Edit ${label}` : undefined} aria-label={label ? `Edit ${label}` : undefined}
className="group relative -mx-1 -my-0.5 flex w-full min-w-0 items-start gap-2 rounded-sm px-1 py-0.5 text-left font-mono text-sm text-zinc-900 hover:bg-yellow-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-yellow-400" className="group flex w-full min-w-0 items-start gap-2 -mx-2 rounded-md px-2 py-1 text-left font-mono text-[13px] text-zinc-900 transition-colors hover:bg-zinc-100/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900"
> >
<span className="min-w-0 flex-1 break-all"> <span className="min-w-0 flex-1 break-all">
{value || <em className="not-italic text-zinc-400"></em>} {value || <em className="not-italic text-zinc-400"></em>}
</span> </span>
<span <span
aria-hidden="true" aria-hidden="true"
className="hidden shrink-0 pt-0.5 text-[10px] font-semibold uppercase tracking-widest text-zinc-400 group-hover:inline" className="hidden shrink-0 self-center text-[10px] font-medium uppercase tracking-wider text-zinc-400 group-hover:inline"
> >
edit edit
</span> </span>
@ -95,8 +91,8 @@ export default function EditableCell({
} }
return ( return (
<div className="relative -mx-1 -my-0.5 flex flex-col"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5">
<input <input
ref={inputRef} ref={inputRef}
value={draft} value={draft}
@ -111,29 +107,29 @@ export default function EditableCell({
} }
}} }}
disabled={isPending} 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" className="w-full min-w-0 rounded-md border-0 bg-zinc-100 px-2 py-1 font-mono text-base text-zinc-900 outline-none ring-1 ring-zinc-300 focus:bg-white focus:ring-2 focus:ring-zinc-900 disabled:opacity-60 sm:text-[13px]"
/> />
<button <button
type="button" type="button"
onClick={commit} onClick={commit}
disabled={isPending} disabled={isPending}
aria-label="Save" aria-label="Save"
className="shrink-0 border border-zinc-900 bg-zinc-900 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-white hover:bg-zinc-700 disabled:opacity-60" className="shrink-0 rounded-md bg-zinc-900 px-2.5 py-1 text-[11px] font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-60"
> >
{isPending ? "…" : "save"} {isPending ? "…" : "Save"}
</button> </button>
<button <button
type="button" type="button"
onClick={cancel} onClick={cancel}
disabled={isPending} disabled={isPending}
aria-label="Cancel" aria-label="Cancel"
className="shrink-0 border border-zinc-300 bg-white px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-zinc-600 hover:border-zinc-500 hover:text-zinc-900 disabled:opacity-60" className="shrink-0 rounded-md px-2.5 py-1 text-[11px] font-medium text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 disabled:opacity-60"
> >
Cancel
</button> </button>
</div> </div>
{error && ( {error && (
<p className="mt-1 font-mono text-[11px] text-red-700" role="alert"> <p className="font-mono text-[11px] text-red-600" role="alert">
{error} {error}
</p> </p>
)} )}

View File

@ -3,14 +3,13 @@
import { useMemo, useOptimistic, useState, useTransition } from "react"; import { useMemo, useOptimistic, useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { User } from "@/lib/types"; import type { User } from "@/lib/types";
import { updateUser } from "@/app/actions"; import { deleteUser, updateUser } from "@/app/actions";
import EditableCell from "./editable-cell"; import EditableCell from "./editable-cell";
import ConfirmDialog from "./confirm-dialog";
type Props = { initial: User[]; prefixPattern: string }; type Props = { initial: User[]; prefixPattern: string };
type SortDir = "asc" | "desc"; type SortDir = "asc" | "desc";
type SortKey = "f_username" | "last_update_time"; type SortKey = "f_username" | "last_update_time";
type OptimisticPatch = { type OptimisticPatch = {
f_username: string; f_username: string;
field: keyof Pick<User, "f_password" | "t_username" | "t_password">; field: keyof Pick<User, "f_password" | "t_username" | "t_password">;
@ -45,7 +44,6 @@ function formatTime(t: string | null) {
const d = new Date(t); const d = new Date(t);
if (Number.isNaN(d.getTime())) return t; if (Number.isNaN(d.getTime())) return t;
return d.toLocaleString(undefined, { return d.toLocaleString(undefined, {
year: "numeric",
month: "short", month: "short",
day: "2-digit", day: "2-digit",
hour: "2-digit", hour: "2-digit",
@ -53,6 +51,27 @@ function formatTime(t: string | null) {
}); });
} }
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) { export default function UsersTable({ initial, prefixPattern }: Props) {
const router = useRouter(); const router = useRouter();
const [sortKey, setSortKey] = useState<SortKey>("last_update_time"); const [sortKey, setSortKey] = useState<SortKey>("last_update_time");
@ -60,6 +79,9 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
const [editingKey, setEditingKey] = useState<string | null>(null); const [editingKey, setEditingKey] = useState<string | null>(null);
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [refreshing, setRefreshing] = useState(false); 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>( const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>(
initial, initial,
@ -83,10 +105,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
startTransition(async () => { startTransition(async () => {
applyOptimistic({ f_username, field, value }); applyOptimistic({ f_username, field, value });
const row = initial.find((r) => r.f_username === f_username); const row = initial.find((r) => r.f_username === f_username);
if (!row) { if (!row) return resolve({ ok: false, error: "row not found" });
resolve({ ok: false, error: "row not found" });
return;
}
const next = { const next = {
f_username: row.f_username, f_username: row.f_username,
f_password: row.f_password, f_password: row.f_password,
@ -95,8 +114,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
[field]: value, [field]: value,
}; };
const result = await updateUser(next); const result = await updateUser(next);
if (result.ok) resolve({ ok: true }); resolve(result.ok ? { ok: true } : { ok: false, error: result.error });
else resolve({ ok: false, error: result.error });
}); });
}); });
} }
@ -110,36 +128,35 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
} }
function toggleSort(k: SortKey) { function toggleSort(k: SortKey) {
if (sortKey === k) { if (sortKey === k) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
setSortDir((d) => (d === "asc" ? "desc" : "asc")); else {
} else {
setSortKey(k); setSortKey(k);
setSortDir("desc"); setSortDir("desc");
} }
} }
if (initial.length === 0) { async function confirmDelete() {
return ( if (!deleteTarget) return;
<div className="mx-auto max-w-3xl px-4 py-16"> setDeleting(true);
<h1 className="font-mono text-2xl font-bold uppercase tracking-tight text-zinc-900"> setDeleteError(null);
Users const result = await deleteUser(deleteTarget);
</h1> setDeleting(false);
<div className="mt-8 border-2 border-dashed border-zinc-300 bg-white p-10 text-center"> if (result.ok) {
<p className="font-mono text-sm text-zinc-500">No users yet.</p> setDeleteTarget(null);
</div> } else {
</div> setDeleteError(result.error);
); }
} }
function HeaderButton({ k, label }: { k: SortKey; label: string }) { function HeaderTh({ k, label }: { k: SortKey; label: string }) {
const active = sortKey === k; const active = sortKey === k;
return ( return (
<button <button
type="button" type="button"
onClick={() => toggleSort(k)} onClick={() => toggleSort(k)}
className={`inline-flex items-center gap-1 font-mono text-[10px] font-bold uppercase tracking-[0.2em] ${ className={`inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider transition-colors ${
active ? "text-zinc-900" : "text-zinc-700" active ? "text-zinc-900" : "text-zinc-500 hover:text-zinc-900"
} hover:text-zinc-900`} }`}
> >
{label} {label}
<span aria-hidden="true">{active ? (sortDir === "asc" ? "↑" : "↓") : "↕"}</span> <span aria-hidden="true">{active ? (sortDir === "asc" ? "↑" : "↓") : "↕"}</span>
@ -147,71 +164,52 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
); );
} }
return ( if (initial.length === 0) {
<div className="mx-auto max-w-6xl px-4 py-8 sm:py-12"> return (
<div className="flex flex-wrap items-end justify-between gap-4"> <div>
<div> <PageHead count={0} onRefresh={refresh} refreshing={refreshing} />
<p className="font-mono text-[11px] font-bold uppercase tracking-[0.25em] text-zinc-500"> <div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60">
// table <p className="text-sm text-zinc-500">No users yet.</p>
</p>
<h1 className="mt-1 font-mono text-2xl font-bold uppercase tracking-tight text-zinc-900 sm:text-3xl">
Users
</h1>
</div>
<div className="flex items-center gap-3">
<div className="inline-flex items-baseline gap-2 border-2 border-zinc-900 bg-white px-3 py-1.5">
<span className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500">
count
</span>
<span className="font-mono text-lg font-bold text-zinc-900">{optimistic.length}</span>
</div>
<button
type="button"
onClick={refresh}
disabled={refreshing}
className="inline-flex items-center gap-1.5 border-2 border-zinc-900 bg-yellow-300 px-3 py-1.5 font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-zinc-900 hover:bg-zinc-900 hover:text-yellow-300 disabled:opacity-60"
>
<span aria-hidden="true" className={refreshing ? "inline-block animate-spin" : ""}>
</span>
refresh
</button>
</div> </div>
</div> </div>
);
}
<div className="mt-6 hidden border-2 border-zinc-900 bg-white sm:block"> 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"> <table className="w-full table-fixed border-collapse">
<thead> <thead>
<tr className="border-b-2 border-zinc-900 bg-zinc-50"> <tr className="bg-zinc-50/60">
<th className="w-[18%] px-3 py-2 text-left"> <th className="w-[18%] px-5 py-3 text-left">
<HeaderButton k="f_username" label="from username" /> <HeaderTh k="f_username" label="From username" />
</th> </th>
<th className="w-[18%] px-3 py-2 text-left font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-700"> <th className="w-[18%] px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500">
from password From password
</th> </th>
<th className="w-[18%] px-3 py-2 text-left font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-700"> <th className="w-[18%] px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500">
to username To username
</th> </th>
<th className="w-[18%] px-3 py-2 text-left font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-700"> <th className="w-[18%] px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500">
to password To password
</th> </th>
<th className="px-3 py-2 text-left"> <th className="px-5 py-3 text-left">
<HeaderButton k="last_update_time" label="last update" /> <HeaderTh k="last_update_time" label="Last update" />
</th> </th>
<th className="w-12 px-3 py-3" aria-hidden="true" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody className="divide-y divide-zinc-100">
{sorted.map((row) => { {sorted.map((row) => {
const k = (f: string) => `${row.f_username}::${f}`; const k = (f: string) => `${row.f_username}::${f}`;
return ( return (
<tr <tr key={row.f_username} className="transition-colors hover:bg-zinc-50/60">
key={row.f_username} <td className="px-5 py-3 align-middle font-mono text-[13px] font-semibold text-zinc-900">
className="border-b border-zinc-200 last:border-b-0 hover:bg-zinc-50"
>
<td className="px-3 py-2 align-middle font-mono text-sm font-semibold text-zinc-900">
{row.f_username} {row.f_username}
</td> </td>
<td className="px-3 py-2 align-middle"> <td className="px-5 py-3 align-middle">
<EditableCell <EditableCell
value={row.f_password} value={row.f_password}
label={`from password for ${row.f_username}`} label={`from password for ${row.f_username}`}
@ -221,7 +219,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
onSave={(v) => saveCell(row.f_username, "f_password", v)} onSave={(v) => saveCell(row.f_username, "f_password", v)}
/> />
</td> </td>
<td className="px-3 py-2 align-middle"> <td className="px-5 py-3 align-middle">
<EditableCell <EditableCell
value={row.t_username} value={row.t_username}
label={`to username for ${row.f_username}`} label={`to username for ${row.f_username}`}
@ -231,7 +229,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
onSave={(v) => saveCell(row.f_username, "t_username", v)} onSave={(v) => saveCell(row.f_username, "t_username", v)}
/> />
</td> </td>
<td className="px-3 py-2 align-middle"> <td className="px-5 py-3 align-middle">
<EditableCell <EditableCell
value={row.t_password} value={row.t_password}
label={`to password for ${row.f_username}`} label={`to password for ${row.f_username}`}
@ -241,9 +239,18 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
onSave={(v) => saveCell(row.f_username, "t_password", v)} onSave={(v) => saveCell(row.f_username, "t_password", v)}
/> />
</td> </td>
<td className="px-3 py-2 align-middle font-mono text-xs text-zinc-600"> <td className="px-5 py-3 align-middle text-xs text-zinc-500">
{formatTime(row.last_update_time)} {formatTime(row.last_update_time)}
</td> </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> </tr>
); );
})} })}
@ -255,20 +262,26 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
{sorted.map((row) => { {sorted.map((row) => {
const k = (f: string) => `${row.f_username}::${f}`; const k = (f: string) => `${row.f_username}::${f}`;
return ( return (
<div key={row.f_username} className="border-2 border-zinc-900 bg-white p-3"> <div key={row.f_username} className="rounded-2xl bg-white p-5 ring-1 ring-zinc-200/60">
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="font-mono text-base font-bold text-zinc-900"> <span className="font-mono text-base font-semibold text-zinc-900">
{row.f_username} {row.f_username}
</span> </span>
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-zinc-500"> <div className="flex items-center gap-2">
{formatTime(row.last_update_time)} <span className="text-[11px] text-zinc-500">
</span> {formatTime(row.last_update_time)}
</span>
<DeleteButton
label={row.f_username}
onClick={() => {
setDeleteError(null);
setDeleteTarget(row.f_username);
}}
/>
</div>
</div> </div>
<dl className="mt-3 grid grid-cols-[max-content_1fr] items-baseline gap-x-3 gap-y-2 border-t border-zinc-200 pt-3"> <dl className="mt-4 space-y-3 border-t border-zinc-100 pt-4">
<dt className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500"> <MobileRow label="From PW">
from pw
</dt>
<dd>
<EditableCell <EditableCell
value={row.f_password} value={row.f_password}
label={`from password for ${row.f_username}`} label={`from password for ${row.f_username}`}
@ -277,11 +290,8 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
onEditEnd={() => setEditingKey(null)} onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.f_username, "f_password", v)} onSave={(v) => saveCell(row.f_username, "f_password", v)}
/> />
</dd> </MobileRow>
<dt className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500"> <MobileRow label="To User">
to user
</dt>
<dd>
<EditableCell <EditableCell
value={row.t_username} value={row.t_username}
label={`to username for ${row.f_username}`} label={`to username for ${row.f_username}`}
@ -290,11 +300,8 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
onEditEnd={() => setEditingKey(null)} onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.f_username, "t_username", v)} onSave={(v) => saveCell(row.f_username, "t_username", v)}
/> />
</dd> </MobileRow>
<dt className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500"> <MobileRow label="To PW">
to pw
</dt>
<dd>
<EditableCell <EditableCell
value={row.t_password} value={row.t_password}
label={`to password for ${row.f_username}`} label={`to password for ${row.f_username}`}
@ -303,12 +310,83 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
onEditEnd={() => setEditingKey(null)} onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.f_username, "t_password", v)} onSave={(v) => saveCell(row.f_username, "t_password", v)}
/> />
</dd> </MobileRow>
</dl> </dl>
</div> </div>
); );
})} })}
</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> </div>
); );
} }