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" && ( +
+