feat(web): success toast on confirmed create/delete

Adds a small top-centered <Toast> that fires only when the Server
Action returns { ok: true } (i.e., the DB write actually succeeded).
Auto-dismisses after 3s.

Wires both create dialogs (CreateAccountDialog, CreateUserDialog) with
an onSuccess callback that the table parent uses to push the toast,
and the delete confirm-flow does the same. Inline-edit success stays
quiet (no toast) — only add/delete trigger it, per the requested
scope.
This commit is contained in:
yiekheng 2026-05-02 21:20:25 +08:00
parent e3ac94cada
commit eebbcb3db2
5 changed files with 98 additions and 6 deletions

View File

@ -7,6 +7,7 @@ import { deleteAccount, updateAccount } from "@/app/actions";
import EditableCell from "./editable-cell";
import ConfirmDialog from "./confirm-dialog";
import CreateAccountDialog from "./create-account-dialog";
import Toast, { type ToastMessage } from "./toast";
type Props = { initial: Acc[]; prefixPattern: string };
type SortDir = "asc" | "desc";
@ -71,6 +72,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [toast, setToast] = useState<ToastMessage | null>(null);
const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>(
initial,
@ -113,7 +115,9 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
const result = await deleteAccount(deleteTarget);
setDeleting(false);
if (result.ok) {
const deleted = deleteTarget;
setDeleteTarget(null);
setToast({ type: "success", message: `Account ${deleted} deleted` });
} else {
setDeleteError(result.error);
}
@ -290,9 +294,14 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
<CreateAccountDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={(name) =>
setToast({ type: "success", message: `Account ${name} created` })
}
prefixPattern={prefixPattern}
/>
<Toast toast={toast} onDismiss={() => setToast(null)} />
<ConfirmDialog
open={!!deleteTarget}
onCancel={() => {

View File

@ -7,10 +7,11 @@ import FormDialogShell, { Field, inputClass } from "./form-dialog-shell";
type Props = {
open: boolean;
onClose: () => void;
onSuccess?: (username: string) => void;
prefixPattern?: string;
};
export default function CreateAccountDialog({ open, onClose, prefixPattern }: Props) {
export default function CreateAccountDialog({ open, onClose, onSuccess, prefixPattern }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [username, setUsername] = useState("");
@ -35,14 +36,16 @@ export default function CreateAccountDialog({ open, onClose, prefixPattern }: Pr
return;
}
setError(null);
const trimmedUsername = username.trim();
startTransition(async () => {
const result = await createAccount({
username: username.trim(),
username: trimmedUsername,
password,
status,
link,
});
if (result.ok) {
onSuccess?.(trimmedUsername);
onClose();
} else {
setError(result.error);

View File

@ -7,9 +7,10 @@ import FormDialogShell, { Field, inputClass } from "./form-dialog-shell";
type Props = {
open: boolean;
onClose: () => void;
onSuccess?: (fUsername: string) => void;
};
export default function CreateUserDialog({ open, onClose }: Props) {
export default function CreateUserDialog({ open, onClose, onSuccess }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [fUsername, setFUsername] = useState("");
@ -33,14 +34,16 @@ export default function CreateUserDialog({ open, onClose }: Props) {
return;
}
setError(null);
const trimmedFUsername = fUsername.trim();
startTransition(async () => {
const result = await createUser({
f_username: fUsername.trim(),
f_username: trimmedFUsername,
f_password: fPassword,
t_username: tUsername.trim(),
t_password: tPassword,
});
if (result.ok) {
onSuccess?.(trimmedFUsername);
onClose();
} else {
setError(result.error);

57
web/components/toast.tsx Normal file
View File

@ -0,0 +1,57 @@
"use client";
import { useEffect } from "react";
export type ToastMessage = {
type: "success" | "error";
message: string;
};
/**
* Simple top-centered toast. Auto-dismisses after `durationMs` (default
* 3s). Owners hold the ToastMessage state; this component reads it and
* calls onDismiss when the timer fires (or when the toast object
* changes useEffect's cleanup clears any in-flight timer).
*/
export default function Toast({
toast,
onDismiss,
durationMs = 3000,
}: {
toast: ToastMessage | null;
onDismiss: () => void;
durationMs?: number;
}) {
useEffect(() => {
if (!toast) return;
const id = setTimeout(onDismiss, durationMs);
return () => clearTimeout(id);
}, [toast, onDismiss, durationMs]);
if (!toast) return null;
const styles =
toast.type === "success"
? "bg-emerald-50 text-emerald-800 ring-emerald-200"
: "bg-red-50 text-red-800 ring-red-200";
return (
<div
role="status"
aria-live="polite"
className="fixed left-1/2 top-4 z-50 -translate-x-1/2 transform px-4"
>
<div
className={`flex items-center gap-2 rounded-full px-4 py-2 shadow-sm ring-1 ${styles}`}
>
<span
aria-hidden="true"
className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-current/10 text-xs font-bold"
>
{toast.type === "success" ? "✓" : "!"}
</span>
<span className="text-sm font-medium">{toast.message}</span>
</div>
</div>
);
}

View File

@ -7,6 +7,7 @@ import { deleteUser, updateUser } from "@/app/actions";
import EditableCell from "./editable-cell";
import ConfirmDialog from "./confirm-dialog";
import CreateUserDialog from "./create-user-dialog";
import Toast, { type ToastMessage } from "./toast";
type Props = { initial: User[]; prefixPattern: string };
type SortDir = "asc" | "desc";
@ -84,6 +85,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [toast, setToast] = useState<ToastMessage | null>(null);
const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>(
initial,
@ -144,7 +146,9 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
const result = await deleteUser(deleteTarget);
setDeleting(false);
if (result.ok) {
const deleted = deleteTarget;
setDeleteTarget(null);
setToast({ type: "success", message: `User ${deleted} deleted` });
} else {
setDeleteError(result.error);
}
@ -180,7 +184,15 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
No users yet. Click <span className="font-medium text-zinc-700">Add</span> to create one manually.
</p>
</div>
<CreateUserDialog open={createOpen} onClose={() => setCreateOpen(false)} />
<CreateUserDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={(name) =>
setToast({ type: "success", message: `User ${name} created` })
}
/>
<Toast toast={toast} onDismiss={() => setToast(null)} />
</div>
);
}
@ -332,7 +344,15 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
})}
</div>
<CreateUserDialog open={createOpen} onClose={() => setCreateOpen(false)} />
<CreateUserDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={(name) =>
setToast({ type: "success", message: `User ${name} created` })
}
/>
<Toast toast={toast} onDismiss={() => setToast(null)} />
<ConfirmDialog
open={!!deleteTarget}