cm_bot_v2/web/components/form-dialog-shell.tsx
yiekheng e3ac94cada feat(web): manual create flow with input dialog for acc and user
api-server gets /create-acc-data and /create-user-data POST routes
that INSERT into the respective tables with required-field validation.
Frontend adds an 'Add' button next to Refresh in each table head;
opens a native <dialog> form with all fields. Inputs use 16px font on
phone (sm:text-[13px] desktop) so iOS doesn't auto-zoom.

A small form-dialog-shell helper centralizes the modal chrome,
field label, and input class so create-account-dialog and
create-user-dialog stay focused on their fields and validation.
2026-05-02 21:19:24 +08:00

126 lines
3.6 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
/**
* Shared <dialog>-based modal shell. Owners pass form contents and a
* submit handler; this component handles open/close imperatively, Esc
* cancellation, and backdrop click cancellation. Native <dialog> gives
* us focus trapping and ::backdrop styling for free.
*/
export default function FormDialogShell({
open,
onCancel,
onSubmit,
title,
children,
cancelLabel = "Cancel",
submitLabel = "Save",
destructive = false,
pending = false,
error,
}: {
open: boolean;
onCancel: () => void;
onSubmit: () => void;
title: string;
children: React.ReactNode;
cancelLabel?: string;
submitLabel?: string;
destructive?: boolean;
pending?: boolean;
error?: string | null;
}) {
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={() => {
if (!pending) onCancel();
}}
onClick={(e) => {
if (pending) return;
if (e.target === ref.current) onCancel();
}}
className="m-auto w-[min(92vw,480px)] 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();
onSubmit();
}}
className="flex flex-col gap-4 p-6"
>
<h2 className="text-lg font-semibold tracking-tight text-zinc-900">{title}</h2>
<div className="flex flex-col gap-3">{children}</div>
{error && (
<p className="font-mono text-[11px] text-red-600" role="alert">
{error}
</p>
)}
<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"
>
{cancelLabel}
</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 ? "…" : submitLabel}
</button>
</div>
</form>
</dialog>
);
}
/**
* A labelled text input that uses 16px font-size on phones (preventing
* iOS Safari auto-zoom-on-focus) and falls back to a tighter 13px on
* tablets/desktop where there's no auto-zoom behavior.
*/
export function Field({
label,
required,
hint,
children,
}: {
label: string;
required?: boolean;
hint?: string;
children: React.ReactNode;
}) {
return (
<label className="flex flex-col gap-1">
<span className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
{label}
{required && <span className="ml-1 text-red-500">*</span>}
</span>
{children}
{hint && <span className="text-[11px] text-zinc-500">{hint}</span>}
</label>
);
}
export const inputClass =
"w-full min-w-0 rounded-md border-0 bg-zinc-100 px-3 py-2 font-mono text-base text-zinc-900 outline-none ring-1 ring-zinc-300 transition-colors focus:bg-white focus:ring-2 focus:ring-zinc-900 disabled:opacity-60 sm:text-[13px]";