cm_bot_v2/web/components/editable-cell.tsx
yiekheng e507714dc5 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.
2026-05-02 21:17:19 +08:00

139 lines
3.9 KiB
TypeScript

"use client";
import { useEffect, useRef, useState, useTransition } from "react";
type EditableCellProps = {
value: string;
onSave: (next: string) => Promise<{ ok: boolean; error?: string }>;
label?: string;
isCurrentlyEditing?: boolean;
onEditStart?: () => void;
onEditEnd?: () => void;
};
export default function EditableCell({
value,
onSave,
label,
isCurrentlyEditing,
onEditStart,
onEditEnd,
}: EditableCellProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!editing) setDraft(value);
}, [value, editing]);
useEffect(() => {
if (!error) return;
const id = setTimeout(() => setError(null), 3000);
return () => clearTimeout(id);
}, [error]);
function begin() {
setDraft(value);
setEditing(true);
onEditStart?.();
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
function cancel() {
setEditing(false);
setDraft(value);
setError(null);
onEditEnd?.();
}
function commit() {
if (draft === value) {
cancel();
return;
}
startTransition(async () => {
const result = await onSave(draft);
if (result.ok) {
setEditing(false);
setError(null);
onEditEnd?.();
} else {
setError(result.error ?? "Save failed");
}
});
}
if (!editing) {
return (
<button
type="button"
onClick={begin}
aria-label={label ? `Edit ${label}` : undefined}
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 self-center text-[10px] font-medium uppercase tracking-wider text-zinc-400 group-hover:inline"
>
edit
</span>
</button>
);
}
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5">
<input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
disabled={isPending}
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 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"}
</button>
<button
type="button"
onClick={cancel}
disabled={isPending}
aria-label="Cancel"
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="font-mono text-[11px] text-red-600" role="alert">
{error}
</p>
)}
</div>
);
}