refactor: optimised UI and UX

- add tag style to status;
- add button style to action links of each domain;
- add Notiflix for better notification experiences
This commit is contained in:
jamo.siu@gmail.com committed 2026-10-12 16:22:37 +08:00
1 parent 46e6607d46
commit cc5e98f29c
9 files changed
+181 -49

No files matched your search

+31 -24
View File
@@ -1,37 +1,44 @@
"use client"
import { useActionState } from "react"
import { useActionState, useEffect } from "react"
import type { ActionState } from "@/lib/action-state"
import { notifyFailure, notifySuccess, notifyWarning } from "@/lib/notify"
export interface ApplyButtonProps {
action: (prev: ActionState, formData: FormData) => Promise<ActionState>
}
// Result messages now arrive as toasts instead of text under the button, so the card never
// grows and nothing has to be dismissed by hand.
const INITIAL: ActionState = { ok: false }
export function ApplyButton({ action }: ApplyButtonProps) {
const [state, formAction, pending] = useActionState<ActionState, FormData>(action, { ok: false })
const [state, formAction, pending] = useActionState<ActionState, FormData>(action, INITIAL)
// Every run of the action returns a fresh object, so this fires exactly once per
// submission — and stays quiet on mount, where `state` carries no message yet.
useEffect(() => {
if (!state.message) return
// A success that also carries `detail` means the config was written but the restart was
// skipped (see applyConfigAction) — that deserves a warning, not a green tick. When the
// restart failed outright, the detail is the reason, so it joins the message.
if (state.ok) {
if (state.detail) notifyWarning(`${state.message}(${state.detail})`)
else notifySuccess(state.message)
return
}
notifyFailure(state.detail ? `${state.message}:${state.detail}` : state.message)
}, [state])
return (
<div className="space-y-2">
<form action={formAction}>
<button
type="submit"
disabled={pending}
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white">
{pending ? "应用中…" : "应用并重启 dnsmasq"}
</button>
</form>
{state.message ? (
<div className="space-y-1 text-sm">
<p
className={state.ok ? "text-emerald-700 dark:text-emerald-400" : "text-red-600 dark:text-red-400"}>
{state.message}
</p>
{state.detail ? (
<p className="text-xs text-zinc-500 dark:text-zinc-400">{state.detail}</p>
) : null}
</div>
) : null}
</div>
<form action={formAction}>
<button
type="submit"
disabled={pending}
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white">
{pending ? "应用中…" : "应用并重启 dnsmasq"}
</button>
</form>
)
}
+3 -1
View File
@@ -1,5 +1,7 @@
"use client"
import { dangerButtonClass } from "./button-styles"
export function DeleteButton() {
return (
<button
@@ -7,7 +9,7 @@ export function DeleteButton() {
onClick={(event) => {
if (!window.confirm("确定要删除这条记录吗?")) event.preventDefault()
}}
className="text-sm text-red-600 transition-opacity hover:opacity-70 dark:text-red-400">
className={dangerButtonClass}>
删除
</button>
)
+8 -7
View File
@@ -1,9 +1,10 @@
"use client"
import Link from "next/link"
import { useActionState } from "react"
import { useActionState, useEffect } from "react"
import type { ActionState } from "@/lib/action-state"
import type { DnsRecord } from "@/lib/db"
import { notifyFailure } from "@/lib/notify"
const fieldClass =
"w-full rounded-md border border-black/10 bg-white px-3 py-2 text-sm text-zinc-900 outline-none focus:border-zinc-500 dark:border-white/15 dark:bg-white/5 dark:text-zinc-100"
@@ -27,16 +28,16 @@ export function RecordForm({ action, record, submitLabel, cancelHref }: RecordFo
const [state, formAction, pending] = useActionState<ActionState, FormData>(action, { ok: false })
const errors = state.fieldErrors ?? {}
// Form-level failures (e.g. the record was deleted before the save landed) go out as
// toasts; per-field errors stay inline next to the input they describe.
useEffect(() => {
if (state.message) notifyFailure(state.message)
}, [state])
return (
<form action={formAction} className="max-w-xl space-y-4">
{record ? <input type="hidden" name="id" value={record.id} /> : null}
{state.message ? (
<p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-500/10 dark:text-red-300">
{state.message}
</p>
) : null}
<div>
<label className={labelClass} htmlFor="domain">
域名
+28
View File
@@ -0,0 +1,28 @@
// Shared button styling for the records table actions.
//
// These live in one module because the same look is used by the server-rendered edit link and
// toggle button in app/records/page.tsx and by the client DeleteButton component; keeping two
// copies would drift the moment one is restyled.
const base =
"inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-50"
/** Default action: edit, enable/disable. Bordered so it reads as clickable without shouting. */
export const secondaryButtonClass = `${base} border border-black/10 bg-white text-zinc-700 hover:bg-zinc-50 dark:border-white/15 dark:bg-white/5 dark:text-zinc-300 dark:hover:bg-white/10`
/**
* Toggle actions, tinted rather than outlined.
*
* The label flips with the record state, so colour follows what the click will *do* rather
* than what the row currently is: putting a record into service reads green, taking one out
* reads amber. Using the same white as `secondaryButtonClass` here made the two actions look
* like one row of identical buttons.
*/
export const enableButtonClass = `${base} border border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-400 dark:hover:bg-emerald-500/20`
export const disableButtonClass = `${base} border border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-400 dark:hover:bg-amber-500/20`
/**
* Destructive action. Deliberately outlined rather than filled: deletion is confirmed in a
* dialog first, and a solid red block would imply it is the primary action.
*/
export const dangerButtonClass = `${base} border border-red-200 bg-white text-red-600 hover:bg-red-50 dark:border-red-500/30 dark:bg-transparent dark:text-red-400 dark:hover:bg-red-500/10`