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:
parent
dac1e10b5d
commit
e507714dc5
@ -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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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 className="flex flex-wrap items-end justify-between gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-mono text-[11px] font-bold uppercase tracking-[0.25em] text-zinc-500">
|
<PageHead count={optimistic.length} onRefresh={refresh} refreshing={refreshing} />
|
||||||
// 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">
|
||||||
|
{row.username}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<StatusBadge status={row.status} />
|
<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">
|
</div>
|
||||||
<dt className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500">
|
<dl className="mt-4 space-y-3 border-t border-zinc-100 pt-4">
|
||||||
password
|
<CardRow label="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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
88
web/components/confirm-dialog.tsx
Normal file
88
web/components/confirm-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (initial.length === 0) {
|
||||||
return (
|
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>
|
<div>
|
||||||
<p className="font-mono text-[11px] font-bold uppercase tracking-[0.25em] text-zinc-500">
|
<PageHead count={0} onRefresh={refresh} refreshing={refreshing} />
|
||||||
// table
|
<div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60">
|
||||||
</p>
|
<p className="text-sm text-zinc-500">No users yet.</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">
|
||||||
|
<span className="text-[11px] text-zinc-500">
|
||||||
{formatTime(row.last_update_time)}
|
{formatTime(row.last_update_time)}
|
||||||
</span>
|
</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">
|
</div>
|
||||||
<dt className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500">
|
<dl className="mt-4 space-y-3 border-t border-zinc-100 pt-4">
|
||||||
from pw
|
<MobileRow label="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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user