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) };
}
}
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 { useRouter } from "next/navigation";
import type { Acc } from "@/lib/types";
import { updateAccount } from "@/app/actions";
import { deleteAccount, updateAccount } from "@/app/actions";
import EditableCell from "./editable-cell";
import ConfirmDialog from "./confirm-dialog";
type Props = { initial: Acc[]; prefixPattern: string };
type SortDir = "asc" | "desc";
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 }) {
const map: Record<string, { bg: string; fg: string; label: string }> = {
"": { 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" },
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 (
<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}
</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) {
const router = useRouter();
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<Acc[], OptimisticPatch>(
initial,
@ -64,14 +88,10 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
startTransition(async () => {
applyOptimistic({ username, field, value });
const row = initial.find((r) => r.username === username);
if (!row) {
resolve({ ok: false, error: "row not found" });
return;
}
if (!row) return resolve({ ok: false, error: "row not found" });
const next: Acc = { ...row, [field]: value };
const result = await updateAccount(next);
if (result.ok) resolve({ ok: true });
else resolve({ ok: false, error: result.error });
resolve(result.ok ? { ok: true } : { 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) {
return (
<div className="mx-auto max-w-3xl px-4 py-16">
<h1 className="font-mono text-2xl font-bold uppercase tracking-tight text-zinc-900">
Accounts
</h1>
<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">
<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 accounts yet. The monitor will create some on the next run.
</p>
</div>
@ -100,76 +131,39 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
}
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:py-12">
<div className="flex flex-wrap items-end justify-between gap-4">
<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>
<PageHead count={optimistic.length} onRefresh={refresh} refreshing={refreshing} />
<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">
<thead>
<tr className="border-b-2 border-zinc-900 bg-zinc-50">
<th className="w-1/5 px-3 py-2 text-left">
<tr className="bg-zinc-50/60">
<th className="w-[18%] px-5 py-3 text-left">
<button
type="button"
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>
</button>
</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">
password
</th>
<th className="w-[15%] px-3 py-2 text-left font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-700">
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>
<Th>Password</Th>
<Th className="w-[16%]">Status</Th>
<Th>Link</Th>
<th className="w-12 px-3 py-3" aria-hidden="true" />
</tr>
</thead>
<tbody>
<tbody className="divide-y divide-zinc-100">
{sorted.map((row) => {
const k = (f: string) => `${row.username}::${f}`;
return (
<tr
key={row.username}
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">
<tr key={row.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.username}
</td>
<td className="px-3 py-2 align-middle">
<td className="px-5 py-3 align-middle">
<EditableCell
value={row.password}
label={`password for ${row.username}`}
@ -179,7 +173,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
onSave={(v) => saveCell(row.username, "password", v)}
/>
</td>
<td className="px-3 py-2 align-middle">
<td className="px-5 py-3 align-middle">
<div className="flex items-center gap-2">
<StatusBadge status={row.status} />
<EditableCell
@ -192,7 +186,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
/>
</div>
</td>
<td className="px-3 py-2 align-middle">
<td className="px-5 py-3 align-middle">
<EditableCell
value={row.link}
label={`link for ${row.username}`}
@ -202,6 +196,15 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
onSave={(v) => saveCell(row.username, "link", v)}
/>
</td>
<td className="px-3 py-3 text-right align-middle">
<DeleteButton
label={row.username}
onClick={() => {
setDeleteError(null);
setDeleteTarget(row.username);
}}
/>
</td>
</tr>
);
})}
@ -209,20 +212,29 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
</table>
</div>
{/* Mobile cards */}
<div className="mt-6 space-y-3 sm:hidden">
{sorted.map((row) => {
const k = (f: string) => `${row.username}::${f}`;
return (
<div key={row.username} className="border-2 border-zinc-900 bg-white p-3">
<div className="flex items-baseline justify-between">
<span className="font-mono text-base font-bold text-zinc-900">{row.username}</span>
<StatusBadge status={row.status} />
<div key={row.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.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>
<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">
<dt className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500">
password
</dt>
<dd>
<dl className="mt-4 space-y-3 border-t border-zinc-100 pt-4">
<CardRow label="Password">
<EditableCell
value={row.password}
label={`password for ${row.username}`}
@ -231,11 +243,8 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "password", v)}
/>
</dd>
<dt className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500">
status
</dt>
<dd>
</CardRow>
<CardRow label="Status">
<EditableCell
value={row.status}
label={`status for ${row.username}`}
@ -244,11 +253,8 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "status", v)}
/>
</dd>
<dt className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500">
link
</dt>
<dd>
</CardRow>
<CardRow label="Link">
<EditableCell
value={row.link}
label={`link for ${row.username}`}
@ -257,12 +263,98 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "link", v)}
/>
</dd>
</CardRow>
</dl>
</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>
);
}

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 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(() => {
if (!editing) setDraft(value);
}, [value, editing]);
// Auto-clear an error after 3 seconds.
useEffect(() => {
if (!error) return;
const id = setTimeout(() => setError(null), 3000);
@ -56,13 +53,12 @@ export default function EditableCell({
}
function commit() {
const next = draft;
if (next === value) {
if (draft === value) {
cancel();
return;
}
startTransition(async () => {
const result = await onSave(next);
const result = await onSave(draft);
if (result.ok) {
setEditing(false);
setError(null);
@ -79,14 +75,14 @@ export default function EditableCell({
type="button"
onClick={begin}
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">
{value || <em className="not-italic text-zinc-400"></em>}
</span>
<span
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
</span>
@ -95,8 +91,8 @@ export default function EditableCell({
}
return (
<div className="relative -mx-1 -my-0.5 flex flex-col">
<div className="flex items-center gap-1">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5">
<input
ref={inputRef}
value={draft}
@ -111,29 +107,29 @@ export default function EditableCell({
}
}}
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
type="button"
onClick={commit}
disabled={isPending}
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
type="button"
onClick={cancel}
disabled={isPending}
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>
</div>
{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}
</p>
)}

View File

@ -3,14 +3,13 @@
import { useMemo, useOptimistic, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import type { User } from "@/lib/types";
import { updateUser } from "@/app/actions";
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">;
@ -45,7 +44,6 @@ function formatTime(t: string | null) {
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",
@ -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) {
const router = useRouter();
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 [, 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,
@ -83,10 +105,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
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;
}
if (!row) return resolve({ ok: false, error: "row not found" });
const next = {
f_username: row.f_username,
f_password: row.f_password,
@ -95,8 +114,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
[field]: value,
};
const result = await updateUser(next);
if (result.ok) resolve({ ok: true });
else resolve({ ok: false, error: result.error });
resolve(result.ok ? { ok: true } : { ok: false, error: result.error });
});
});
}
@ -110,36 +128,35 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
}
function toggleSort(k: SortKey) {
if (sortKey === k) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
if (sortKey === k) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else {
setSortKey(k);
setSortDir("desc");
}
}
if (initial.length === 0) {
return (
<div className="mx-auto max-w-3xl px-4 py-16">
<h1 className="font-mono text-2xl font-bold uppercase tracking-tight text-zinc-900">
Users
</h1>
<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 users yet.</p>
</div>
</div>
);
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 HeaderButton({ k, label }: { k: SortKey; label: string }) {
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 font-mono text-[10px] font-bold uppercase tracking-[0.2em] ${
active ? "text-zinc-900" : "text-zinc-700"
} hover:text-zinc-900`}
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>
@ -147,71 +164,52 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
);
}
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:py-12">
<div className="flex flex-wrap items-end justify-between gap-4">
<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">
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>
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>
);
}
<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">
<thead>
<tr className="border-b-2 border-zinc-900 bg-zinc-50">
<th className="w-[18%] px-3 py-2 text-left">
<HeaderButton k="f_username" label="from username" />
<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-3 py-2 text-left font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-700">
from password
<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-3 py-2 text-left font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-700">
to username
<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-3 py-2 text-left font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-700">
to password
<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-3 py-2 text-left">
<HeaderButton k="last_update_time" label="last update" />
<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>
<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="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">
<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-3 py-2 align-middle">
<td className="px-5 py-3 align-middle">
<EditableCell
value={row.f_password}
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)}
/>
</td>
<td className="px-3 py-2 align-middle">
<td className="px-5 py-3 align-middle">
<EditableCell
value={row.t_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)}
/>
</td>
<td className="px-3 py-2 align-middle">
<td className="px-5 py-3 align-middle">
<EditableCell
value={row.t_password}
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)}
/>
</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)}
</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>
);
})}
@ -255,20 +262,26 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
{sorted.map((row) => {
const k = (f: string) => `${row.f_username}::${f}`;
return (
<div key={row.f_username} className="border-2 border-zinc-900 bg-white p-3">
<div className="flex items-baseline justify-between gap-2">
<span className="font-mono text-base font-bold text-zinc-900">
<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>
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-zinc-500">
{formatTime(row.last_update_time)}
</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-3 grid grid-cols-[max-content_1fr] items-baseline gap-x-3 gap-y-2 border-t border-zinc-200 pt-3">
<dt className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500">
from pw
</dt>
<dd>
<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}`}
@ -277,11 +290,8 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.f_username, "f_password", v)}
/>
</dd>
<dt className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500">
to user
</dt>
<dd>
</MobileRow>
<MobileRow label="To User">
<EditableCell
value={row.t_username}
label={`to username for ${row.f_username}`}
@ -290,11 +300,8 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.f_username, "t_username", v)}
/>
</dd>
<dt className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500">
to pw
</dt>
<dd>
</MobileRow>
<MobileRow label="To PW">
<EditableCell
value={row.t_password}
label={`to password for ${row.f_username}`}
@ -303,12 +310,83 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.f_username, "t_password", v)}
/>
</dd>
</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>
);
}