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.
This commit is contained in:
parent
e507714dc5
commit
e3ac94cada
@ -59,6 +59,10 @@ class CM_API:
|
||||
# Delete routes
|
||||
self.app.route('/delete-acc-data', methods=['POST'])(self.delete_acc_data)
|
||||
self.app.route('/delete-user-data', methods=['POST'])(self.delete_user_data)
|
||||
|
||||
# Create routes (manual operator input)
|
||||
self.app.route('/create-acc-data', methods=['POST'])(self.create_acc_data)
|
||||
self.app.route('/create-user-data', methods=['POST'])(self.create_user_data)
|
||||
|
||||
def _check_database_available(self):
|
||||
db = self._get_database_connection()
|
||||
@ -224,6 +228,64 @@ class CM_API:
|
||||
finally:
|
||||
self._close_database_connection(db)
|
||||
|
||||
def create_acc_data(self):
|
||||
is_available, db, error_response = self._check_database_available()
|
||||
if not is_available:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
username = (data.get('username') or '').strip()
|
||||
password = data.get('password') or ''
|
||||
status = data.get('status') or ''
|
||||
link = data.get('link') or ''
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({"error": "Username and password are required"}), 400
|
||||
|
||||
result = db.execute(
|
||||
"INSERT INTO acc (username, password, status, link) VALUES (%s, %s, %s, %s)",
|
||||
[username, password, status, link]
|
||||
)
|
||||
|
||||
if result:
|
||||
return jsonify({"created": username})
|
||||
return jsonify({"error": "Failed to create account"}), 500
|
||||
|
||||
except Exception as error:
|
||||
return self._handle_error(error, "Error creating account"), 500
|
||||
finally:
|
||||
self._close_database_connection(db)
|
||||
|
||||
def create_user_data(self):
|
||||
is_available, db, error_response = self._check_database_available()
|
||||
if not is_available:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
f_username = (data.get('f_username') or '').strip()
|
||||
f_password = data.get('f_password') or ''
|
||||
t_username = (data.get('t_username') or '').strip()
|
||||
t_password = data.get('t_password') or ''
|
||||
|
||||
if not f_username or not f_password or not t_username or not t_password:
|
||||
return jsonify({"error": "All fields are required"}), 400
|
||||
|
||||
result = db.execute(
|
||||
"INSERT INTO user (f_username, f_password, t_username, t_password) VALUES (%s, %s, %s, %s)",
|
||||
[f_username, f_password, t_username, t_password]
|
||||
)
|
||||
|
||||
if result:
|
||||
return jsonify({"created": f_username})
|
||||
return jsonify({"error": "Failed to create user"}), 500
|
||||
|
||||
except Exception as error:
|
||||
return self._handle_error(error, "Error creating user"), 500
|
||||
finally:
|
||||
self._close_database_connection(db)
|
||||
|
||||
def run(self, port=3000, debug=None):
|
||||
if debug is None:
|
||||
debug = _debug_enabled()
|
||||
|
||||
@ -26,6 +26,26 @@ export async function updateUser(data: UserUpdate): Promise<ActionResult> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAccount(data: AccUpdate): Promise<ActionResult> {
|
||||
try {
|
||||
await fetchApi("/create-acc-data", { method: "POST", body: data });
|
||||
revalidatePath("/");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createUser(data: UserUpdate): Promise<ActionResult> {
|
||||
try {
|
||||
await fetchApi("/create-user-data", { method: "POST", body: data });
|
||||
revalidatePath("/users");
|
||||
return { ok: true };
|
||||
} catch (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 } });
|
||||
|
||||
@ -6,6 +6,7 @@ import type { Acc } from "@/lib/types";
|
||||
import { deleteAccount, updateAccount } from "@/app/actions";
|
||||
import EditableCell from "./editable-cell";
|
||||
import ConfirmDialog from "./confirm-dialog";
|
||||
import CreateAccountDialog from "./create-account-dialog";
|
||||
|
||||
type Props = { initial: Acc[]; prefixPattern: string };
|
||||
type SortDir = "asc" | "desc";
|
||||
@ -69,6 +70,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>(
|
||||
initial,
|
||||
@ -120,19 +122,34 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
||||
if (initial.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<PageHead count={0} onRefresh={refresh} refreshing={refreshing} />
|
||||
<PageHead
|
||||
count={0}
|
||||
onRefresh={refresh}
|
||||
refreshing={refreshing}
|
||||
onAdd={() => setCreateOpen(true)}
|
||||
/>
|
||||
<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.
|
||||
No accounts yet. Click <span className="font-medium text-zinc-700">Add</span> above to create one manually, or wait for the monitor.
|
||||
</p>
|
||||
</div>
|
||||
<CreateAccountDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
prefixPattern={prefixPattern}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHead count={optimistic.length} onRefresh={refresh} refreshing={refreshing} />
|
||||
<PageHead
|
||||
count={optimistic.length}
|
||||
onRefresh={refresh}
|
||||
refreshing={refreshing}
|
||||
onAdd={() => setCreateOpen(true)}
|
||||
/>
|
||||
|
||||
{/* Desktop / tablet table */}
|
||||
<div className="mt-6 hidden overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200/60 sm:block">
|
||||
@ -270,6 +287,12 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<CreateAccountDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
prefixPattern={prefixPattern}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onCancel={() => {
|
||||
@ -326,10 +349,12 @@ function PageHead({
|
||||
count,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
onAdd,
|
||||
}: {
|
||||
count: number;
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
onAdd: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
@ -344,17 +369,27 @@ function PageHead({
|
||||
</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 className="flex items-center gap-2">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-zinc-900 px-4 py-2 text-xs font-medium text-white shadow-sm transition-colors hover:bg-zinc-700"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
109
web/components/create-account-dialog.tsx
Normal file
109
web/components/create-account-dialog.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { createAccount } from "@/app/actions";
|
||||
import FormDialogShell, { Field, inputClass } from "./form-dialog-shell";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
prefixPattern?: string;
|
||||
};
|
||||
|
||||
export default function CreateAccountDialog({ open, onClose, prefixPattern }: Props) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [link, setLink] = useState("");
|
||||
|
||||
// Reset on open.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setStatus("");
|
||||
setLink("");
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
function submit() {
|
||||
if (!username.trim() || !password) {
|
||||
setError("Username and password are required");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await createAccount({
|
||||
username: username.trim(),
|
||||
password,
|
||||
status,
|
||||
link,
|
||||
});
|
||||
if (result.ok) {
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDialogShell
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onSubmit={submit}
|
||||
title="New account"
|
||||
submitLabel="Create"
|
||||
pending={pending}
|
||||
error={error}
|
||||
>
|
||||
<Field label="Username" required hint={prefixPattern ? `Suggested prefix: ${prefixPattern}` : undefined}>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={pending}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={prefixPattern ? `${prefixPattern}1234` : "username"}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Password" required>
|
||||
<input
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={pending}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Status" hint="Empty | wait | done">
|
||||
<input
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
disabled={pending}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="(leave blank for available)"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Link">
|
||||
<input
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
disabled={pending}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="https://..."
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
</FormDialogShell>
|
||||
);
|
||||
}
|
||||
104
web/components/create-user-dialog.tsx
Normal file
104
web/components/create-user-dialog.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { createUser } from "@/app/actions";
|
||||
import FormDialogShell, { Field, inputClass } from "./form-dialog-shell";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function CreateUserDialog({ open, onClose }: Props) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fUsername, setFUsername] = useState("");
|
||||
const [fPassword, setFPassword] = useState("");
|
||||
const [tUsername, setTUsername] = useState("");
|
||||
const [tPassword, setTPassword] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFUsername("");
|
||||
setFPassword("");
|
||||
setTUsername("");
|
||||
setTPassword("");
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
function submit() {
|
||||
if (!fUsername.trim() || !fPassword || !tUsername.trim() || !tPassword) {
|
||||
setError("All fields are required");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await createUser({
|
||||
f_username: fUsername.trim(),
|
||||
f_password: fPassword,
|
||||
t_username: tUsername.trim(),
|
||||
t_password: tPassword,
|
||||
});
|
||||
if (result.ok) {
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDialogShell
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onSubmit={submit}
|
||||
title="New user pairing"
|
||||
submitLabel="Create"
|
||||
pending={pending}
|
||||
error={error}
|
||||
>
|
||||
<Field label="From username" required>
|
||||
<input
|
||||
value={fUsername}
|
||||
onChange={(e) => setFUsername(e.target.value)}
|
||||
disabled={pending}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="From password" required>
|
||||
<input
|
||||
value={fPassword}
|
||||
onChange={(e) => setFPassword(e.target.value)}
|
||||
disabled={pending}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="To username" required>
|
||||
<input
|
||||
value={tUsername}
|
||||
onChange={(e) => setTUsername(e.target.value)}
|
||||
disabled={pending}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="To password (security PIN)" required>
|
||||
<input
|
||||
value={tPassword}
|
||||
onChange={(e) => setTPassword(e.target.value)}
|
||||
disabled={pending}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
</FormDialogShell>
|
||||
);
|
||||
}
|
||||
125
web/components/form-dialog-shell.tsx
Normal file
125
web/components/form-dialog-shell.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
"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]";
|
||||
@ -6,6 +6,7 @@ import type { User } from "@/lib/types";
|
||||
import { deleteUser, updateUser } from "@/app/actions";
|
||||
import EditableCell from "./editable-cell";
|
||||
import ConfirmDialog from "./confirm-dialog";
|
||||
import CreateUserDialog from "./create-user-dialog";
|
||||
|
||||
type Props = { initial: User[]; prefixPattern: string };
|
||||
type SortDir = "asc" | "desc";
|
||||
@ -82,6 +83,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>(
|
||||
initial,
|
||||
@ -167,17 +169,30 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
||||
if (initial.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<PageHead count={0} onRefresh={refresh} refreshing={refreshing} />
|
||||
<PageHead
|
||||
count={0}
|
||||
onRefresh={refresh}
|
||||
refreshing={refreshing}
|
||||
onAdd={() => setCreateOpen(true)}
|
||||
/>
|
||||
<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>
|
||||
<p className="text-sm text-zinc-500">
|
||||
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)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHead count={optimistic.length} onRefresh={refresh} refreshing={refreshing} />
|
||||
<PageHead
|
||||
count={optimistic.length}
|
||||
onRefresh={refresh}
|
||||
refreshing={refreshing}
|
||||
onAdd={() => setCreateOpen(true)}
|
||||
/>
|
||||
|
||||
<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">
|
||||
@ -317,6 +332,8 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<CreateUserDialog open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onCancel={() => {
|
||||
@ -362,10 +379,12 @@ function PageHead({
|
||||
count,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
onAdd,
|
||||
}: {
|
||||
count: number;
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
onAdd: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
@ -376,17 +395,27 @@ function PageHead({
|
||||
<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 className="flex items-center gap-2">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-zinc-900 px-4 py-2 text-xs font-medium text-white shadow-sm transition-colors hover:bg-zinc-700"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user