From e783692ac9cb1cf17cd3394e68e881ccbe6ab18a Mon Sep 17 00:00:00 2001 From: Chillln <8766520@gmail.com> Date: Thu, 9 Oct 2025 09:35:13 +0800 Subject: [PATCH] feat: enhance public notes functionality with flexible options (#141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: enhance public notes functionality with flexible options - Make public notes optional to prevent default values causing frontend issues - Add dual editing modes: raw text editing and custom fields (avoid hardcoded schema) - Set raw text mode as default, populate input on edit, submit raw text content always - Add toggle switch: submit raw text when enabled, submit empty & hide controls when disabled - New records default to disabled public notes; auto-expand on edit based on content * chore: auto-fix linting and formatting issues * feat: Add public annotation data structure and utility functions Implemented Zod validation patterns, default values, parsing functions, and utility functions for public notes, and updated related internationalization text. * chore: auto-fix linting and formatting issues * refactor(server): Replace i18n implementation Replace direct use of i18n.t with react-i18next's useTranslation hook to improve internationalization support. * refactor(public-note): Optimize data model and validation logic Removed the pruneEmpty function and simplified the date processing logic, making billingDataMod and planDataMod optional fields. Also optimized the validation logic to handle optional fields. * chore: auto-fix linting and formatting issues * fix zod validation & don't write empty values when parsing * use raw mode if object contains unknown fields * rename some features * chore: Update dependency package versions Upgrade multiple npm dependencies to their latest versions, including react, tailwindcss, and eslint. Ignore lock files. * fix(server): Fix default value when bill amount is undefined Changed undefined values ​​for bill amount to the default value "0" to avoid potential null value errors. --------- Co-authored-by: Guccen <171530509+Chillln@users.noreply.github.com> Co-authored-by: uubulb --- .gitignore | 2 + package.json | 42 +- src/components/server.tsx | 1274 +++++++++++----------- src/components/ui/alert-dialog.tsx | 2 +- src/components/ui/avatar.tsx | 2 +- src/components/ui/breadcrumb.tsx | 2 +- src/components/ui/button.tsx | 2 +- src/components/ui/calendar.tsx | 2 +- src/components/ui/card.tsx | 13 +- src/components/ui/checkbox.tsx | 2 +- src/components/ui/combobox.tsx | 2 +- src/components/ui/command.tsx | 2 +- src/components/ui/dialog.tsx | 2 +- src/components/ui/drawer.tsx | 8 +- src/components/ui/dropdown-menu.tsx | 2 +- src/components/ui/form.tsx | 109 +- src/components/ui/input.tsx | 30 +- src/components/ui/label.tsx | 2 +- src/components/ui/navigation-menu.tsx | 2 +- src/components/ui/pagination.tsx | 5 +- src/components/ui/popover.tsx | 2 +- src/components/ui/scroll-area.tsx | 2 +- src/components/ui/select.tsx | 2 +- src/components/ui/separator.tsx | 2 +- src/components/ui/switch.tsx | 2 +- src/components/ui/table.tsx | 102 +- src/components/ui/tabs.tsx | 2 +- src/components/ui/textarea.tsx | 2 +- src/components/xui/filepath.tsx | 2 +- src/components/xui/multi-select.tsx | 2 +- src/components/xui/overlayless-sheet.tsx | 54 +- src/hooks/useMediaQuery.tsx | 2 +- src/lib/public-note.ts | 182 ++++ src/locales/en/translation.json | 21 +- src/locales/zh-CN/translation.json | 21 +- src/main.tsx | 31 +- 36 files changed, 1087 insertions(+), 851 deletions(-) create mode 100644 src/lib/public-note.ts diff --git a/.gitignore b/.gitignore index a547bf3..4e29aac 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? +bun.lock +pnpm-lock.yaml diff --git a/package.json b/package.json index 0a57257..20c3022 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", - "@tailwindcss/postcss": "^4.1.13", + "@tailwindcss/postcss": "^4.1.14", "@tanstack/react-table": "^8.21.3", "@trivago/prettier-plugin-sort-imports": "^5.2.2", "@types/luxon": "^3.7.1", @@ -41,44 +41,44 @@ "copy-to-clipboard": "^3.3.3", "date-fns": "^4.1.0", "framer-motion": "^12.23.22", - "i18next": "^25.5.2", + "i18next": "^25.5.3", "i18next-browser-languagedetector": "^8.2.0", "jotai-zustand": "^0.6.0", - "lucide-react": "^0.544.0", + "lucide-react": "^0.545.0", "luxon": "^3.7.2", "next-themes": "^0.4.6", "prettier-plugin-tailwindcss": "^0.6.14", - "react": "^19.1.1", - "react-day-picker": "^9.11.0", - "react-dom": "^19.1.1", - "react-hook-form": "^7.63.0", + "react": "^19.2.0", + "react-day-picker": "^9.11.1", + "react-dom": "^19.2.0", + "react-hook-form": "^7.64.0", "react-i18next": "^16.0.0", - "react-router-dom": "^7.9.3", + "react-router-dom": "^7.9.4", "react-virtuoso": "^4.14.1", "sonner": "^2.0.7", "swr": "^2.3.6", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", - "zod": "^4.1.11", + "zod": "^4.1.12", "zustand": "^5.0.8" }, "devDependencies": { - "@eslint/js": "^9.36.0", - "@types/node": "^24.5.2", - "@types/react": "^19.1.15", - "@types/react-dom": "^19.1.9", + "@eslint/js": "^9.37.0", + "@types/node": "^24.7.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.1", "@vitejs/plugin-react": "^5.0.4", "autoprefixer": "^10.4.21", - "eslint": "^9.36.0", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.22", + "eslint": "^9.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "eslint-plugin-react-refresh": "^0.4.23", "globals": "^16.4.0", "postcss": "^8.5.6", - "swagger-typescript-api": "^13.2.13", - "tailwindcss": "^4.1.13", - "typescript": "~5.9.2", - "typescript-eslint": "^8.44.1", - "vite": "^7.1.7" + "swagger-typescript-api": "^13.2.15", + "tailwindcss": "^4.1.14", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.0", + "vite": "^7.1.9" } } diff --git a/src/components/server.tsx b/src/components/server.tsx index 66ddc2f..05260ff 100644 --- a/src/components/server.tsx +++ b/src/components/server.tsx @@ -34,10 +34,22 @@ import { import { Switch } from "@/components/ui/switch" import { Textarea } from "@/components/ui/textarea" import { IconButton } from "@/components/xui/icon-button" +import { + type PublicNote, + PublicNoteSchema, + applyPublicNoteDate, + applyPublicNotePatch, + detectPublicNoteMode, + normalizeISO, + parsePublicNote, + toggleEndNoExpiry, + validatePublicNote, +} from "@/lib/public-note" import { conv } from "@/lib/utils" import { asOptionalField } from "@/lib/utils" import { ModelServer } from "@/types" import { zodResolver } from "@hookform/resolvers/zod" +import { HelpCircle } from "lucide-react" import { useState } from "react" import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" @@ -53,7 +65,22 @@ interface ServerCardProps { const serverFormSchema = z.object({ name: z.string().min(1), note: asOptionalField(z.string()), - public_note: asOptionalField(z.string()), + public_note: asOptionalField( + z.string().refine( + (val) => { + const s = (val ?? "").trim() + if (s.length === 0) return true + try { + const obj = JSON.parse(s) + return PublicNoteSchema.safeParse(obj).success + } catch { + // skip check if not JSON + return true + } + }, + { message: "Invalid Public Note JSON" }, + ), + ), display_index: z.coerce.number().int(), hide_for_guest: asOptionalField(z.boolean()), enable_ddns: asOptionalField(z.boolean()), @@ -95,71 +122,6 @@ export const ServerCard: React.FC = ({ data, mutate }) => { const [open, setOpen] = useState(false) - type PublicNote = { - billingDataMod: { - startDate: string - endDate: string - autoRenewal: string - cycle: string - amount: string - } - planDataMod: { - bandwidth: string - trafficVol: string - trafficType: string - IPv4: string - IPv6: string - networkRoute: string - extra: string - } - } - - const defaultPublicNote: PublicNote = { - billingDataMod: { - startDate: "", - endDate: "", - autoRenewal: "", - cycle: "", - amount: "", - }, - planDataMod: { - bandwidth: "", - trafficVol: "", - trafficType: "", - IPv4: "0", - IPv6: "0", - networkRoute: "", - extra: "", - }, - } - - const parsePublicNote = (s?: string): PublicNote => { - if (!s) return defaultPublicNote - try { - const obj = JSON.parse(s) - return { - billingDataMod: { - startDate: obj?.billingDataMod?.startDate ?? "", - endDate: obj?.billingDataMod?.endDate ?? "", - autoRenewal: obj?.billingDataMod?.autoRenewal ?? "", - cycle: obj?.billingDataMod?.cycle ?? "", - amount: obj?.billingDataMod?.amount ?? "", - }, - planDataMod: { - bandwidth: obj?.planDataMod?.bandwidth ?? "", - trafficVol: obj?.planDataMod?.trafficVol ?? "", - trafficType: obj?.planDataMod?.trafficType ?? "", - IPv4: obj?.planDataMod?.IPv4 === "1" ? "1" : "0", - IPv6: obj?.planDataMod?.IPv6 === "1" ? "1" : "0", - networkRoute: obj?.planDataMod?.networkRoute ?? "", - extra: obj?.planDataMod?.extra ?? "", - }, - } - } catch { - return defaultPublicNote - } - } - const [publicNoteObj, setPublicNoteObj] = useState( parsePublicNote(data?.public_note), ) @@ -182,41 +144,22 @@ export const ServerCard: React.FC = ({ data, mutate }) => { > >({}) - const isValidISOLike = (v: string) => { - if (!v) return true - // special marker for "no expiry" - if (v === "0000-00-00T23:59:59+08:00") return true - const d = new Date(v) - return !isNaN(d.getTime()) + const [publicNoteMode, setPublicNoteMode] = useState<"structured" | "raw">( + detectPublicNoteMode(data?.public_note), + ) + const [publicNoteRaw, setPublicNoteRaw] = useState(data?.public_note ?? "") + + const patchPublicNote = (path: string, value: string | undefined) => { + setPublicNoteObj((prev) => applyPublicNotePatch(prev, path, value)) } - - const validatePublicNote = (pn: PublicNote) => { - const errs: Partial> = {} - - if (pn.billingDataMod.startDate && !isValidISOLike(pn.billingDataMod.startDate)) { - errs["billing.startDate"] = t("Validation.InvalidDate") - } - if (pn.billingDataMod.endDate && !isValidISOLike(pn.billingDataMod.endDate)) { - errs["billing.endDate"] = t("Validation.InvalidDate") - } - if (pn.billingDataMod.autoRenewal && !/^(0|1)$/.test(pn.billingDataMod.autoRenewal)) { - errs["billing.autoRenewal"] = t("Validation.MustBe0Or1") - } - if (pn.billingDataMod.cycle && !/^(Day|Week|Month|Year)$/i.test(pn.billingDataMod.cycle)) { - errs["billing.cycle"] = t("Validation.MustBeDayWeekMonthYear") - } - // amount 允许任意非空字符串或空 - if (pn.planDataMod.trafficType && !/^(1|2)$/.test(pn.planDataMod.trafficType)) { - errs["plan.trafficType"] = t("Validation.MustBe1Or2") - } - if (!/^(0|1)$/.test(pn.planDataMod.IPv4)) { - errs["plan.IPv4"] = t("Validation.MustBe0Or1") - } - if (!/^(0|1)$/.test(pn.planDataMod.IPv6)) { - errs["plan.IPv6"] = t("Validation.MustBe0Or1") - } - - return { errors: errs, valid: Object.keys(errs).length === 0 } + const patchPublicNoteDate = ( + path: "billingDataMod.startDate" | "billingDataMod.endDate", + d: Date, + ) => { + setPublicNoteObj((prev) => applyPublicNoteDate(prev, path, d)) + } + const toggleEndNoExpiryLocal = () => { + setPublicNoteObj((prev) => toggleEndNoExpiry(prev)) } const onSubmit = async (values: any) => { @@ -228,35 +171,36 @@ export const ServerCard: React.FC = ({ data, mutate }) => { ? JSON.parse(values.override_ddns_domains_raw) : undefined - // validate structured fields - const { errors, valid } = validatePublicNote(publicNoteObj) - if (!valid) { - setPublicNoteErrors(errors) - toast(t("Error"), { description: t("Validation.InvalidForm") }) - return - } - setPublicNoteErrors({}) + if (publicNoteMode === "raw") { + const raw = (publicNoteRaw ?? "").trim() + if (raw.length === 0) { + values.public_note = undefined + } else { + values.public_note = raw + } + } else { + const { errors, valid } = validatePublicNote(publicNoteObj) + if (!valid) { + setPublicNoteErrors(errors) + toast(t("Error"), { description: t("Validation.InvalidForm") }) + return + } + setPublicNoteErrors({}) - // normalize datetime-local to ISO string if provided - const normalizeISO = (v: string) => { - if (!v) return v - // keep special "no expiry" value as-is - if (v === "0000-00-00T23:59:59+08:00") return v - const date = new Date(v) - return isNaN(date.getTime()) ? v : date.toISOString() - } - const pnNormalized: PublicNote = { - billingDataMod: { - ...publicNoteObj.billingDataMod, - startDate: normalizeISO(publicNoteObj.billingDataMod.startDate), - endDate: normalizeISO(publicNoteObj.billingDataMod.endDate), - // keep others as-is - }, - planDataMod: { ...publicNoteObj.planDataMod }, + const bd = publicNoteObj.billingDataMod + const pd = publicNoteObj.planDataMod + const pnNormalized: PublicNote = { + billingDataMod: bd && { + ...bd, + startDate: normalizeISO(bd.startDate), + endDate: normalizeISO(bd.endDate), + }, + planDataMod: pd, + } + const jsonStr = JSON.stringify(pnNormalized) + values.public_note = jsonStr.length > 2 ? jsonStr : undefined } - // serialize structured public note back to JSON string - values.public_note = JSON.stringify(pnNormalized) await updateServer(data!.id!, values) } catch (e) { console.error(e) @@ -404,526 +348,602 @@ export const ServerCard: React.FC = ({ data, mutate }) => { )} /> - {/* Structured Public Note fields */} + {/* Public Note controls (optional + dual mode) */}
- {t("Public") + t("Note")} -

- {t("PublicNote.DropdownHint")} -

+
+
+ {t("PublicNote.Label")} + + + +
+
-
-
- {t("PublicNote.Billing")} + {/* Toggle: when disabled, hide edit controls and submit an empty value */} +
+ {/* Mode switch: Raw text / Custom fields */} +
+ {/* Show 'structured' first, then 'raw' */} + +
-
-
- - - +
+ + {/* Raw text mode: shown by default; submission uses this string */} + {publicNoteMode === "raw" && ( +
+