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
@ -60,6 +60,10 @@ class CM_API:
|
|||||||
self.app.route('/delete-acc-data', methods=['POST'])(self.delete_acc_data)
|
self.app.route('/delete-acc-data', methods=['POST'])(self.delete_acc_data)
|
||||||
self.app.route('/delete-user-data', methods=['POST'])(self.delete_user_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):
|
def _check_database_available(self):
|
||||||
db = self._get_database_connection()
|
db = self._get_database_connection()
|
||||||
if db is None:
|
if db is None:
|
||||||
@ -224,6 +228,64 @@ class CM_API:
|
|||||||
finally:
|
finally:
|
||||||
self._close_database_connection(db)
|
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):
|
def run(self, port=3000, debug=None):
|
||||||
if debug is None:
|
if debug is None:
|
||||||
debug = _debug_enabled()
|
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> {
|
export async function deleteAccount(username: string): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
await fetchApi("/delete-acc-data", { method: "POST", body: { username } });
|
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 { deleteAccount, updateAccount } from "@/app/actions";
|
||||||
import EditableCell from "./editable-cell";
|
import EditableCell from "./editable-cell";
|
||||||
import ConfirmDialog from "./confirm-dialog";
|
import ConfirmDialog from "./confirm-dialog";
|
||||||
|
import CreateAccountDialog from "./create-account-dialog";
|
||||||
|
|
||||||
type Props = { initial: Acc[]; prefixPattern: string };
|
type Props = { initial: Acc[]; prefixPattern: string };
|
||||||
type SortDir = "asc" | "desc";
|
type SortDir = "asc" | "desc";
|
||||||
@ -69,6 +70,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>(
|
const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>(
|
||||||
initial,
|
initial,
|
||||||
@ -120,19 +122,34 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
|||||||
if (initial.length === 0) {
|
if (initial.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<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">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<CreateAccountDialog
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
prefixPattern={prefixPattern}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHead count={optimistic.length} onRefresh={refresh} refreshing={refreshing} />
|
<PageHead
|
||||||
|
count={optimistic.length}
|
||||||
|
onRefresh={refresh}
|
||||||
|
refreshing={refreshing}
|
||||||
|
onAdd={() => setCreateOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Desktop / tablet table */}
|
{/* Desktop / tablet table */}
|
||||||
<div className="mt-6 hidden overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200/60 sm:block">
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<CreateAccountDialog
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
prefixPattern={prefixPattern}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
@ -326,10 +349,12 @@ function PageHead({
|
|||||||
count,
|
count,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
refreshing,
|
refreshing,
|
||||||
|
onAdd,
|
||||||
}: {
|
}: {
|
||||||
count: number;
|
count: number;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
|
onAdd: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
@ -344,17 +369,27 @@ function PageHead({
|
|||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={onRefresh}
|
type="button"
|
||||||
disabled={refreshing}
|
onClick={onRefresh}
|
||||||
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"
|
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 aria-hidden="true" className={refreshing ? "inline-block animate-spin" : ""}>
|
||||||
</span>
|
⟳
|
||||||
Refresh
|
</span>
|
||||||
</button>
|
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>
|
</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 { deleteUser, updateUser } from "@/app/actions";
|
||||||
import EditableCell from "./editable-cell";
|
import EditableCell from "./editable-cell";
|
||||||
import ConfirmDialog from "./confirm-dialog";
|
import ConfirmDialog from "./confirm-dialog";
|
||||||
|
import CreateUserDialog from "./create-user-dialog";
|
||||||
|
|
||||||
type Props = { initial: User[]; prefixPattern: string };
|
type Props = { initial: User[]; prefixPattern: string };
|
||||||
type SortDir = "asc" | "desc";
|
type SortDir = "asc" | "desc";
|
||||||
@ -82,6 +83,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>(
|
const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>(
|
||||||
initial,
|
initial,
|
||||||
@ -167,17 +169,30 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
|||||||
if (initial.length === 0) {
|
if (initial.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<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">
|
<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>
|
</div>
|
||||||
|
<CreateUserDialog open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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">
|
<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">
|
<table className="w-full table-fixed border-collapse">
|
||||||
@ -317,6 +332,8 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CreateUserDialog open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
@ -362,10 +379,12 @@ function PageHead({
|
|||||||
count,
|
count,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
refreshing,
|
refreshing,
|
||||||
|
onAdd,
|
||||||
}: {
|
}: {
|
||||||
count: number;
|
count: number;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
|
onAdd: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
<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>
|
<span className="ml-2 align-middle text-base font-medium text-zinc-400">{count}</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={onRefresh}
|
type="button"
|
||||||
disabled={refreshing}
|
onClick={onRefresh}
|
||||||
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"
|
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 aria-hidden="true" className={refreshing ? "inline-block animate-spin" : ""}>
|
||||||
</span>
|
⟳
|
||||||
Refresh
|
</span>
|
||||||
</button>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user