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:
9 files changed
+172
-40
No files matched your search
@@ -1,17 +1,37 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useActionState } from "react"
|
import { useActionState, useEffect } from "react"
|
||||||
import type { ActionState } from "@/lib/action-state"
|
import type { ActionState } from "@/lib/action-state"
|
||||||
|
import { notifyFailure, notifySuccess, notifyWarning } from "@/lib/notify"
|
||||||
|
|
||||||
export interface ApplyButtonProps {
|
export interface ApplyButtonProps {
|
||||||
action: (prev: ActionState, formData: FormData) => Promise<ActionState>
|
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) {
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
|
||||||
<form action={formAction}>
|
<form action={formAction}>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -20,18 +40,5 @@ export function ApplyButton({ action }: ApplyButtonProps) {
|
|||||||
{pending ? "应用中…" : "应用并重启 dnsmasq"}
|
{pending ? "应用中…" : "应用并重启 dnsmasq"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { dangerButtonClass } from "./button-styles"
|
||||||
|
|
||||||
export function DeleteButton() {
|
export function DeleteButton() {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -7,7 +9,7 @@ export function DeleteButton() {
|
|||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (!window.confirm("确定要删除这条记录吗?")) event.preventDefault()
|
if (!window.confirm("确定要删除这条记录吗?")) event.preventDefault()
|
||||||
}}
|
}}
|
||||||
className="text-sm text-red-600 transition-opacity hover:opacity-70 dark:text-red-400">
|
className={dangerButtonClass}>
|
||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useActionState } from "react"
|
import { useActionState, useEffect } from "react"
|
||||||
import type { ActionState } from "@/lib/action-state"
|
import type { ActionState } from "@/lib/action-state"
|
||||||
import type { DnsRecord } from "@/lib/db"
|
import type { DnsRecord } from "@/lib/db"
|
||||||
|
import { notifyFailure } from "@/lib/notify"
|
||||||
|
|
||||||
const fieldClass =
|
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"
|
"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 [state, formAction, pending] = useActionState<ActionState, FormData>(action, { ok: false })
|
||||||
const errors = state.fieldErrors ?? {}
|
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 (
|
return (
|
||||||
<form action={formAction} className="max-w-xl space-y-4">
|
<form action={formAction} className="max-w-xl space-y-4">
|
||||||
{record ? <input type="hidden" name="id" value={record.id} /> : null}
|
{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>
|
<div>
|
||||||
<label className={labelClass} htmlFor="domain">
|
<label className={labelClass} htmlFor="domain">
|
||||||
域名
|
域名
|
||||||
|
|||||||
@@ -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`
|
||||||
+33
-15
@@ -4,12 +4,27 @@ import { env } from "@/lib/env"
|
|||||||
import { listRecords } from "@/lib/records"
|
import { listRecords } from "@/lib/records"
|
||||||
import { applyConfigAction, deleteRecordAction, toggleRecordAction } from "./actions"
|
import { applyConfigAction, deleteRecordAction, toggleRecordAction } from "./actions"
|
||||||
import { ApplyButton } from "./_components/ApplyButton"
|
import { ApplyButton } from "./_components/ApplyButton"
|
||||||
|
import {
|
||||||
|
disableButtonClass,
|
||||||
|
enableButtonClass,
|
||||||
|
secondaryButtonClass,
|
||||||
|
} from "./_components/button-styles"
|
||||||
import { DeleteButton } from "./_components/DeleteButton"
|
import { DeleteButton } from "./_components/DeleteButton"
|
||||||
|
|
||||||
export const metadata = { title: "域名记录" }
|
export const metadata = { title: "域名记录" }
|
||||||
|
|
||||||
const cardClass = "rounded-lg border border-black/10 p-4 dark:border-white/15"
|
const cardClass = "rounded-lg border border-black/10 p-4 dark:border-white/15"
|
||||||
|
|
||||||
|
// Status is shown as a tag rather than plain text so the column can be skimmed without
|
||||||
|
// reading the label. The dot carries the same meaning for anyone who cannot tell the two
|
||||||
|
// greens/zincs apart.
|
||||||
|
const statusTagBase =
|
||||||
|
"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset"
|
||||||
|
const statusTagEnabled = `${statusTagBase} bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-400 dark:ring-emerald-500/20`
|
||||||
|
const statusTagDisabled = `${statusTagBase} bg-zinc-100 text-zinc-600 ring-zinc-500/20 dark:bg-white/5 dark:text-zinc-400 dark:ring-white/15`
|
||||||
|
const statusDotEnabled = "size-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400"
|
||||||
|
const statusDotDisabled = "size-1.5 rounded-full bg-zinc-400 dark:bg-zinc-500"
|
||||||
|
|
||||||
export default function RecordsPage() {
|
export default function RecordsPage() {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
@@ -73,37 +88,40 @@ async function RecordTable() {
|
|||||||
<tbody className="divide-y divide-black/5 dark:divide-white/10">
|
<tbody className="divide-y divide-black/5 dark:divide-white/10">
|
||||||
{records.map((record) => (
|
{records.map((record) => (
|
||||||
<tr key={record.id}>
|
<tr key={record.id}>
|
||||||
<td className="px-4 py-2 font-mono text-zinc-900 dark:text-zinc-100">{record.domain}</td>
|
<td className="px-4 py-2 font-mono text-zinc-900 dark:text-zinc-100">
|
||||||
|
{record.domain}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2 text-zinc-600 dark:text-zinc-400">{record.type}</td>
|
<td className="px-4 py-2 text-zinc-600 dark:text-zinc-400">{record.type}</td>
|
||||||
<td className="px-4 py-2 font-mono text-zinc-600 dark:text-zinc-400">{record.value}</td>
|
<td className="px-4 py-2 font-mono text-zinc-600 dark:text-zinc-400">
|
||||||
|
{record.value}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
|
<span className={record.enabled ? statusTagEnabled : statusTagDisabled}>
|
||||||
<span
|
<span
|
||||||
className={
|
aria-hidden="true"
|
||||||
record.enabled
|
className={record.enabled ? statusDotEnabled : statusDotDisabled}
|
||||||
? "text-emerald-700 dark:text-emerald-400"
|
/>
|
||||||
: "text-zinc-400 dark:text-zinc-500"
|
|
||||||
}>
|
|
||||||
{record.enabled ? "已启用" : "已禁用"}
|
{record.enabled ? "已启用" : "已禁用"}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-zinc-600 dark:text-zinc-400">{record.comment || "—"}</td>
|
<td className="px-4 py-2 text-zinc-600 dark:text-zinc-400">
|
||||||
|
{record.comment || "—"}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link href={`/records/${record.id}`} className={secondaryButtonClass}>
|
||||||
href={`/records/${record.id}`}
|
|
||||||
className="text-sm text-zinc-700 transition-opacity hover:opacity-70 dark:text-zinc-300">
|
|
||||||
编辑
|
编辑
|
||||||
</Link>
|
</Link>
|
||||||
<form action={toggleRecordAction} className="inline">
|
<form action={toggleRecordAction} className="inline-flex">
|
||||||
<input type="hidden" name="id" value={record.id} />
|
<input type="hidden" name="id" value={record.id} />
|
||||||
<input type="hidden" name="enabled" value={record.enabled ? "0" : "1"} />
|
<input type="hidden" name="enabled" value={record.enabled ? "0" : "1"} />
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="text-sm text-zinc-700 transition-opacity hover:opacity-70 dark:text-zinc-300">
|
className={record.enabled ? disableButtonClass : enableButtonClass}>
|
||||||
{record.enabled ? "禁用" : "启用"}
|
{record.enabled ? "禁用" : "启用"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form action={deleteRecordAction} className="inline">
|
<form action={deleteRecordAction} className="inline-flex">
|
||||||
<input type="hidden" name="id" value={record.id} />
|
<input type="hidden" name="id" value={record.id} />
|
||||||
<DeleteButton />
|
<DeleteButton />
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Notify } from "notiflix/build/notiflix-notify-aio"
|
||||||
|
|
||||||
|
// The Notify AIO build ships its styles inline and injects them on first use, so unlike a
|
||||||
|
// React toaster there is no element to mount: configuring the module once here is the whole
|
||||||
|
// integration. Components call the notify* helpers below instead of touching notiflix, so
|
||||||
|
// placement, colouring and timing all live in this one module.
|
||||||
|
|
||||||
|
/** Failures carry detail worth reading, so they outlive the routine 3s default. */
|
||||||
|
const FAILURE_TIMEOUT_MS = 6000
|
||||||
|
|
||||||
|
// Notify has no dark-mode option of its own, so toasts are coloured with the same tinted
|
||||||
|
// palette the app already uses for status tags and buttons, switching on the same
|
||||||
|
// prefers-colour-scheme query globals.css does.
|
||||||
|
const toastTheme = {
|
||||||
|
light: {
|
||||||
|
success: { background: "#ecfdf5", textColor: "#047857" }, // emerald-50 / emerald-700
|
||||||
|
warning: { background: "#fffbeb", textColor: "#b45309" }, // amber-50 / amber-700
|
||||||
|
failure: { background: "#fef2f2", textColor: "#dc2626" }, // red-50 / red-600
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
success: { background: "rgba(16, 185, 129, 0.1)", textColor: "#34d399" }, // emerald-500/10 / emerald-400
|
||||||
|
warning: { background: "rgba(245, 158, 11, 0.1)", textColor: "#fbbf24" }, // amber-500/10 / amber-400
|
||||||
|
failure: { background: "rgba(239, 68, 68, 0.1)", textColor: "#f87171" }, // red-500/10 / red-400
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module bodies also run on the server during SSR, so guard the setup. The import itself is
|
||||||
|
// safe on the server — Notiflix only touches the DOM when a toast is actually shown.
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const dark = window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
|
||||||
|
const applyTheme = () => {
|
||||||
|
const theme = dark.matches ? toastTheme.dark : toastTheme.light
|
||||||
|
Notify.init({
|
||||||
|
position: "right-top",
|
||||||
|
// The 280px default squeezes the file paths in apply messages onto too many lines.
|
||||||
|
width: "400px",
|
||||||
|
// Messages wrap rather than widen the toast, and file paths show up in them, so allow
|
||||||
|
// well past the 110-character default before Notiflix starts truncating.
|
||||||
|
messageMaxLength: 500,
|
||||||
|
success: theme.success,
|
||||||
|
warning: theme.warning,
|
||||||
|
failure: theme.failure,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme()
|
||||||
|
// Track the OS theme if it changes while the page is open.
|
||||||
|
dark.addEventListener("change", applyTheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifySuccess(message: string) {
|
||||||
|
Notify.success(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyWarning(message: string) {
|
||||||
|
Notify.warning(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyFailure(message: string) {
|
||||||
|
// Per-type options cannot carry a timeout (it is read from the common settings only), so
|
||||||
|
// the longer delay is applied per call.
|
||||||
|
Notify.failure(message, { timeout: FAILURE_TIMEOUT_MS })
|
||||||
|
}
|
||||||
+2
-1
@@ -22,6 +22,7 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
allowedDevOrigins: ["192.168.31.227"],
|
||||||
|
}
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"@prisma/client": "^7.10.0",
|
"@prisma/client": "^7.10.0",
|
||||||
"dayjs": "^1.11.23",
|
"dayjs": "^1.11.23",
|
||||||
"next": "16.4.0",
|
"next": "16.4.0",
|
||||||
|
"notiflix": "^3.2.8",
|
||||||
"react": "19.3.0",
|
"react": "19.3.0",
|
||||||
"react-dom": "19.3.0"
|
"react-dom": "19.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+8
@@ -178,6 +178,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: 16.4.0
|
specifier: 16.4.0
|
||||||
version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@24.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@24.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
||||||
|
notiflix:
|
||||||
|
specifier: ^3.2.8
|
||||||
|
version: 3.2.8
|
||||||
react:
|
react:
|
||||||
specifier: 19.3.0
|
specifier: 19.3.0
|
||||||
version: 19.3.0
|
version: 19.3.0
|
||||||
@@ -2282,6 +2285,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==}
|
resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
notiflix@3.2.8:
|
||||||
|
resolution: {integrity: sha512-BNBGr3qNIBNt6NZYvch0OLF9DEEgWKcjYzISMH9eECKwfsUaINb5K/RA21hy5kmfOtdbdAdODrqxts+0xp+xwA==}
|
||||||
|
|
||||||
object-assign@4.1.1:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -4990,6 +4996,8 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.57: {}
|
node-releases@2.0.57: {}
|
||||||
|
|
||||||
|
notiflix@3.2.8: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
|
|||||||
Reference in new issue
Block a user