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) };
|
||||
}
|
||||
}
|
||||
|
||||
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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
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 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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user