From e3ac94cada3ecc6672dd46655cee3ff8e1157ce8 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 2 May 2026 21:19:24 +0800 Subject: [PATCH] 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 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. --- app/cm_api.py | 62 +++++++++++ web/app/actions.ts | 20 ++++ web/components/accounts-table.tsx | 63 +++++++++--- web/components/create-account-dialog.tsx | 109 ++++++++++++++++++++ web/components/create-user-dialog.tsx | 104 +++++++++++++++++++ web/components/form-dialog-shell.tsx | 125 +++++++++++++++++++++++ web/components/users-table.tsx | 57 ++++++++--- 7 files changed, 512 insertions(+), 28 deletions(-) create mode 100644 web/components/create-account-dialog.tsx create mode 100644 web/components/create-user-dialog.tsx create mode 100644 web/components/form-dialog-shell.tsx diff --git a/app/cm_api.py b/app/cm_api.py index a521316..5c01e93 100644 --- a/app/cm_api.py +++ b/app/cm_api.py @@ -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() diff --git a/web/app/actions.ts b/web/app/actions.ts index 4b5da62..68a954f 100644 --- a/web/app/actions.ts +++ b/web/app/actions.ts @@ -26,6 +26,26 @@ export async function updateUser(data: UserUpdate): Promise { } } +export async function createAccount(data: AccUpdate): Promise { + 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 { + 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 { try { await fetchApi("/delete-acc-data", { method: "POST", body: { username } }); diff --git a/web/components/accounts-table.tsx b/web/components/accounts-table.tsx index c8b08a2..7dccdb2 100644 --- a/web/components/accounts-table.tsx +++ b/web/components/accounts-table.tsx @@ -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(null); const [deleting, setDeleting] = useState(false); const [deleteError, setDeleteError] = useState(null); + const [createOpen, setCreateOpen] = useState(false); const [optimistic, applyOptimistic] = useOptimistic( initial, @@ -120,19 +122,34 @@ export default function AccountsTable({ initial, prefixPattern }: Props) { if (initial.length === 0) { return (
- + setCreateOpen(true)} + />

- No accounts yet. The monitor will create some on the next run. + No accounts yet. Click Add above to create one manually, or wait for the monitor.

+ setCreateOpen(false)} + prefixPattern={prefixPattern} + />
); } return (
- + setCreateOpen(true)} + /> {/* Desktop / tablet table */}
@@ -270,6 +287,12 @@ export default function AccountsTable({ initial, prefixPattern }: Props) { })}
+ setCreateOpen(false)} + prefixPattern={prefixPattern} + /> + { @@ -326,10 +349,12 @@ function PageHead({ count, onRefresh, refreshing, + onAdd, }: { count: number; onRefresh: () => void; refreshing: boolean; + onAdd: () => void; }) { return (
@@ -344,17 +369,27 @@ function PageHead({
- +
+ + +
); } diff --git a/web/components/create-account-dialog.tsx b/web/components/create-account-dialog.tsx new file mode 100644 index 0000000..7cbcf9d --- /dev/null +++ b/web/components/create-account-dialog.tsx @@ -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(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 ( + + + setUsername(e.target.value)} + disabled={pending} + autoFocus + autoComplete="off" + spellCheck={false} + placeholder={prefixPattern ? `${prefixPattern}1234` : "username"} + className={inputClass} + /> + + + setPassword(e.target.value)} + disabled={pending} + autoComplete="off" + spellCheck={false} + className={inputClass} + /> + + + setStatus(e.target.value)} + disabled={pending} + autoComplete="off" + spellCheck={false} + placeholder="(leave blank for available)" + className={inputClass} + /> + + + setLink(e.target.value)} + disabled={pending} + autoComplete="off" + spellCheck={false} + placeholder="https://..." + className={inputClass} + /> + + + ); +} diff --git a/web/components/create-user-dialog.tsx b/web/components/create-user-dialog.tsx new file mode 100644 index 0000000..be82c92 --- /dev/null +++ b/web/components/create-user-dialog.tsx @@ -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(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 ( + + + setFUsername(e.target.value)} + disabled={pending} + autoFocus + autoComplete="off" + spellCheck={false} + className={inputClass} + /> + + + setFPassword(e.target.value)} + disabled={pending} + autoComplete="off" + spellCheck={false} + className={inputClass} + /> + + + setTUsername(e.target.value)} + disabled={pending} + autoComplete="off" + spellCheck={false} + className={inputClass} + /> + + + setTPassword(e.target.value)} + disabled={pending} + autoComplete="off" + spellCheck={false} + className={inputClass} + /> + + + ); +} diff --git a/web/components/form-dialog-shell.tsx b/web/components/form-dialog-shell.tsx new file mode 100644 index 0000000..f6b8aef --- /dev/null +++ b/web/components/form-dialog-shell.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +/** + * Shared -based modal shell. Owners pass form contents and a + * submit handler; this component handles open/close imperatively, Esc + * cancellation, and backdrop click cancellation. Native 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(null); + + useEffect(() => { + const dialog = ref.current; + if (!dialog) return; + if (open && !dialog.open) dialog.showModal(); + else if (!open && dialog.open) dialog.close(); + }, [open]); + + return ( + { + 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" + > +
{ + e.preventDefault(); + onSubmit(); + }} + className="flex flex-col gap-4 p-6" + > +

{title}

+
{children}
+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+
+ ); +} + +/** + * 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 ( + + ); +} + +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]"; diff --git a/web/components/users-table.tsx b/web/components/users-table.tsx index 6d50556..bd3316c 100644 --- a/web/components/users-table.tsx +++ b/web/components/users-table.tsx @@ -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(null); const [deleting, setDeleting] = useState(false); const [deleteError, setDeleteError] = useState(null); + const [createOpen, setCreateOpen] = useState(false); const [optimistic, applyOptimistic] = useOptimistic( initial, @@ -167,17 +169,30 @@ export default function UsersTable({ initial, prefixPattern }: Props) { if (initial.length === 0) { return (
- + setCreateOpen(true)} + />
-

No users yet.

+

+ No users yet. Click Add to create one manually. +

+ setCreateOpen(false)} />
); } return (
- + setCreateOpen(true)} + />
@@ -317,6 +332,8 @@ export default function UsersTable({ initial, prefixPattern }: Props) { })} + setCreateOpen(false)} /> + { @@ -362,10 +379,12 @@ function PageHead({ count, onRefresh, refreshing, + onAdd, }: { count: number; onRefresh: () => void; refreshing: boolean; + onAdd: () => void; }) { return (
@@ -376,17 +395,27 @@ function PageHead({ {count}
- +
+ + +
); }