feat: enhance public notes functionality with flexible options (#141)

* 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 <uub@suwako.de>
This commit is contained in:
Chillln
2025-10-09 09:35:13 +08:00
committed by GitHub
parent ec6511bcb8
commit e783692ac9
36 changed files with 1087 additions and 851 deletions
+2
View File
@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
bun.lock
pnpm-lock.yaml
+21 -21
View File
@@ -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"
}
}
+352 -332
View File
@@ -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<ServerCardProps> = ({ 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<PublicNote>(
parsePublicNote(data?.public_note),
)
@@ -182,41 +144,22 @@ export const ServerCard: React.FC<ServerCardProps> = ({ 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<string>(data?.public_note ?? "")
const validatePublicNote = (pn: PublicNote) => {
const errs: Partial<Record<string, string>> = {}
if (pn.billingDataMod.startDate && !isValidISOLike(pn.billingDataMod.startDate)) {
errs["billing.startDate"] = t("Validation.InvalidDate")
const patchPublicNote = (path: string, value: string | undefined) => {
setPublicNoteObj((prev) => applyPublicNotePatch(prev, path, value))
}
if (pn.billingDataMod.endDate && !isValidISOLike(pn.billingDataMod.endDate)) {
errs["billing.endDate"] = t("Validation.InvalidDate")
const patchPublicNoteDate = (
path: "billingDataMod.startDate" | "billingDataMod.endDate",
d: Date,
) => {
setPublicNoteObj((prev) => applyPublicNoteDate(prev, path, d))
}
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 toggleEndNoExpiryLocal = () => {
setPublicNoteObj((prev) => toggleEndNoExpiry(prev))
}
const onSubmit = async (values: any) => {
@@ -228,7 +171,14 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
? JSON.parse(values.override_ddns_domains_raw)
: undefined
// validate structured fields
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)
@@ -237,26 +187,20 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
}
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 bd = publicNoteObj.billingDataMod
const pd = publicNoteObj.planDataMod
const pnNormalized: PublicNote = {
billingDataMod: {
...publicNoteObj.billingDataMod,
startDate: normalizeISO(publicNoteObj.billingDataMod.startDate),
endDate: normalizeISO(publicNoteObj.billingDataMod.endDate),
// keep others as-is
billingDataMod: bd && {
...bd,
startDate: normalizeISO(bd.startDate),
endDate: normalizeISO(bd.endDate),
},
planDataMod: { ...publicNoteObj.planDataMod },
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,15 +348,72 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
</FormItem>
)}
/>
{/* Structured Public Note fields */}
{/* Public Note controls (optional + dual mode) */}
<div className="space-y-3">
<div className="space-y-1">
<FormLabel>{t("Public") + t("Note")}</FormLabel>
<p className="text-xs text-muted-foreground">
{t("PublicNote.DropdownHint")}
</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FormLabel>{t("PublicNote.Label")}</FormLabel>
<a
href="https://nezha.wiki/guide/servers.html#%E5%85%AC%E5%BC%80%E5%A4%87%E6%B3%A8%E8%AE%BE%E7%BD%AE"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-muted-foreground hover:text-foreground"
>
<HelpCircle className="h-4 w-4" />
</a>
</div>
</div>
</div>
{/* Toggle: when disabled, hide edit controls and submit an empty value */}
<div className="flex items-center gap-4">
{/* Mode switch: Raw text / Custom fields */}
<div className="flex items-center gap-2">
{/* Show 'structured' first, then 'raw' */}
<Button
type="button"
variant={
publicNoteMode === "structured"
? "default"
: "outline"
}
className="text-xs h-7"
onClick={() => {
setPublicNoteMode("structured")
setPublicNoteObj(parsePublicNote(publicNoteRaw))
}}
>
{t("PublicNote.CustomFields")}
</Button>
<Button
type="button"
variant={
publicNoteMode === "raw" ? "default" : "outline"
}
className="text-xs h-7"
onClick={() => setPublicNoteMode("raw")}
>
{t("PublicNote.RawText")}
</Button>
</div>
</div>
{/* Raw text mode: shown by default; submission uses this string */}
{publicNoteMode === "raw" && (
<div>
<Textarea
className="resize-y"
value={publicNoteRaw}
onChange={(e) => setPublicNoteRaw(e.target.value)}
rows={10}
/>
</div>
)}
{/* Custom fields mode: keep structured editing; serialize to string on submit */}
{publicNoteMode === "structured" && (
<>
<div className="rounded-md border p-3 space-y-3">
<div className="text-sm font-medium opacity-80">
{t("PublicNote.Billing")}
@@ -422,15 +423,30 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
<Label className="text-xs">
{t("PublicNote.StartDate")}
</Label>
{/* Add 'Clear' button to allow removing the date */}
<Button
type="button"
variant="outline"
className="text-xs px-2 py-0 h-auto bg-gray-200 dark:bg-gray-700 ml-2"
onClick={() =>
patchPublicNote(
"billingDataMod.startDate",
undefined,
)
}
>
{t("PublicNote.ClearDate") ?? "Clear"}
</Button>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
>
{publicNoteObj.billingDataMod.startDate
{publicNoteObj.billingDataMod
?.startDate
? new Date(
publicNoteObj.billingDataMod.startDate,
publicNoteObj.billingDataMod!.startDate!,
).toLocaleDateString()
: "YYYY-MM-DD"}
</Button>
@@ -444,47 +460,27 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
className="w-full min-h-[320px]"
mode="single"
captionLayout="dropdown"
startMonth={new Date(2000, 0)}
endMonth={new Date(2050, 11)}
startMonth={
new Date(2000, 0)
}
endMonth={
new Date(2050, 11)
}
selected={
publicNoteObj.billingDataMod
.startDate
publicNoteObj
.billingDataMod
?.startDate
? new Date(
publicNoteObj.billingDataMod.startDate,
publicNoteObj.billingDataMod!.startDate!,
)
: undefined
}
onSelect={(d) => {
if (!d) return
setPublicNoteObj((prev) => {
const prevDateStr =
prev.billingDataMod
.startDate
if (prevDateStr) {
const pd = new Date(
prevDateStr,
patchPublicNoteDate(
"billingDataMod.startDate",
d,
)
// 仅在有效日期时复制时分秒
if (
!isNaN(pd.getTime())
) {
d.setHours(
pd.getHours(),
pd.getMinutes(),
pd.getSeconds(),
0,
)
}
}
return {
...prev,
billingDataMod: {
...prev.billingDataMod,
startDate:
d.toISOString(),
},
}
})
}}
autoFocus
/>
@@ -493,7 +489,11 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
</Popover>
{publicNoteErrors["billing.startDate"] && (
<p className="text-xs text-destructive mt-1">
{publicNoteErrors["billing.startDate"]}
{
publicNoteErrors[
"billing.startDate"
]
}
</p>
)}
</div>
@@ -506,26 +506,29 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
type="button"
variant="outline"
className="text-xs px-2 py-0 h-auto bg-gray-200 dark:bg-gray-700"
onClick={() =>
setPublicNoteObj((prev) => ({
...prev,
billingDataMod: {
...prev.billingDataMod,
endDate:
prev.billingDataMod
.endDate ===
"0000-00-00T23:59:59+08:00"
? ""
: "0000-00-00T23:59:59+08:00",
},
}))
}
onClick={toggleEndNoExpiryLocal}
>
{publicNoteObj.billingDataMod.endDate ===
{publicNoteObj.billingDataMod
?.endDate ===
"0000-00-00T23:59:59+08:00"
? t("PublicNote.CancelNoExpiry")
: t("PublicNote.SetNoExpiry")}
</Button>
{/* Add 'Clear' button to allow removing the date */}
<Button
type="button"
variant="outline"
className="text-xs px-2 py-0 h-auto bg-gray-200 dark:bg-gray-700"
onClick={() =>
patchPublicNote(
"billingDataMod.endDate",
undefined,
)
}
>
{t("PublicNote.ClearDate") ??
"Clear"}
</Button>
</div>
<Popover>
<PopoverTrigger asChild>
@@ -533,13 +536,19 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
variant="outline"
className="w-full justify-start text-left font-normal"
>
{publicNoteObj.billingDataMod.endDate
? publicNoteObj.billingDataMod
.endDate ===
{publicNoteObj.billingDataMod
?.endDate
? publicNoteObj
.billingDataMod
?.endDate ===
"0000-00-00T23:59:59+08:00"
? t("PublicNote.NoExpiry")
? t(
"PublicNote.NoExpiry",
)
: new Date(
publicNoteObj.billingDataMod.endDate,
publicNoteObj
.billingDataMod
?.endDate as string,
).toLocaleDateString()
: "YYYY-MM-DD"}
</Button>
@@ -553,50 +562,33 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
className="w-full min-h-[320px]"
mode="single"
captionLayout="dropdown"
startMonth={new Date(2000, 0)}
endMonth={new Date(2050, 11)}
startMonth={
new Date(2000, 0)
}
endMonth={
new Date(2050, 11)
}
selected={
publicNoteObj.billingDataMod
.endDate &&
publicNoteObj.billingDataMod
.endDate !==
publicNoteObj
.billingDataMod
?.endDate &&
publicNoteObj
.billingDataMod
?.endDate !==
"0000-00-00T23:59:59+08:00"
? new Date(
publicNoteObj.billingDataMod.endDate,
publicNoteObj
.billingDataMod
?.endDate as string,
)
: undefined
}
onSelect={(d) => {
if (!d) return
setPublicNoteObj((prev) => {
const prevDateStr =
prev.billingDataMod
.endDate
if (prevDateStr) {
const pd = new Date(
prevDateStr,
patchPublicNoteDate(
"billingDataMod.endDate",
d,
)
// 仅在有效日期时复制时分秒(特殊“不过期”值不会影响)
if (
!isNaN(pd.getTime())
) {
d.setHours(
pd.getHours(),
pd.getMinutes(),
pd.getSeconds(),
0,
)
}
}
return {
...prev,
billingDataMod: {
...prev.billingDataMod,
endDate:
d.toISOString(),
},
}
})
}}
autoFocus
/>
@@ -606,64 +598,81 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
{publicNoteErrors["billing.endDate"] && (
<p className="text-xs text-destructive mt-1">
{publicNoteErrors["billing.endDate"]}
{
publicNoteErrors[
"billing.endDate"
]
}
</p>
)}
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Label className="text-xs">
{t("PublicNote.AutoRenewal")}
</Label>
<Select
onValueChange={(val) =>
setPublicNoteObj((prev) => ({
...prev,
billingDataMod: {
...prev.billingDataMod,
autoRenewal: val,
},
}))
}
defaultValue={
publicNoteObj.billingDataMod.autoRenewal ||
"0"
}
>
<SelectTrigger>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">
{t("PublicNote.Enabled")}
</SelectItem>
<SelectItem value="0">
</div>
<div className="flex items-center gap-2 mt-3">
<span className="text-xs">
{t("PublicNote.Disabled")}
</SelectItem>
</SelectContent>
</Select>
{publicNoteErrors["billing.autoRenewal"] && (
</span>
<Switch
checked={
publicNoteObj.billingDataMod
?.autoRenewal === "1"
}
onCheckedChange={(checked) =>
patchPublicNote(
"billingDataMod.autoRenewal",
checked ? "1" : undefined,
)
}
/>
<span className="text-xs">
{t("PublicNote.Enabled")}
</span>
</div>
{publicNoteErrors[
"billing.autoRenewal"
] && (
<p className="text-xs text-destructive mt-1">
{publicNoteErrors["billing.autoRenewal"]}
{
publicNoteErrors[
"billing.autoRenewal"
]
}
</p>
)}
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Label className="text-xs">
{t("PublicNote.Cycle")}
</Label>
<Button
type="button"
variant="outline"
className="text-xs px-2 py-0 h-auto bg-gray-200 dark:bg-gray-700"
onClick={() =>
patchPublicNote(
"billingDataMod.cycle",
undefined,
)
}
>
{t("PublicNote.Clear") ?? "Clear"}
</Button>
</div>
<Select
onValueChange={(val) =>
setPublicNoteObj((prev) => ({
...prev,
billingDataMod: {
...prev.billingDataMod,
cycle: val,
},
}))
patchPublicNote(
"billingDataMod.cycle",
val,
)
}
defaultValue={
publicNoteObj.billingDataMod.cycle ||
"Month"
value={
publicNoteObj.billingDataMod?.cycle
}
>
<SelectTrigger>
@@ -700,13 +709,10 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
variant="outline"
className="text-xs px-2 py-0 h-auto bg-gray-200 dark:bg-gray-700"
onClick={() =>
setPublicNoteObj((prev) => ({
...prev,
billingDataMod: {
...prev.billingDataMod,
amount: "0",
},
}))
patchPublicNote(
"billingDataMod.amount",
"0",
)
}
>
{t("PublicNote.Free")}
@@ -716,13 +722,10 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
variant="outline"
className="text-xs px-2 py-0 h-auto bg-gray-200 dark:bg-gray-700"
onClick={() =>
setPublicNoteObj((prev) => ({
...prev,
billingDataMod: {
...prev.billingDataMod,
amount: "-1",
},
}))
patchPublicNote(
"billingDataMod.amount",
"-1",
)
}
>
{t("PublicNote.PayAsYouGo")}
@@ -730,15 +733,14 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
</div>
<Input
placeholder="200EUR"
value={publicNoteObj.billingDataMod.amount}
value={
publicNoteObj.billingDataMod?.amount
}
onChange={(e) =>
setPublicNoteObj((prev) => ({
...prev,
billingDataMod: {
...prev.billingDataMod,
amount: e.target.value,
},
}))
patchPublicNote(
"billingDataMod.amount",
e.target.value,
)
}
/>
</div>
@@ -756,15 +758,14 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
</Label>
<Input
placeholder="30Mbps"
value={publicNoteObj.planDataMod.bandwidth}
value={
publicNoteObj.planDataMod?.bandwidth
}
onChange={(e) =>
setPublicNoteObj((prev) => ({
...prev,
planDataMod: {
...prev.planDataMod,
bandwidth: e.target.value,
},
}))
patchPublicNote(
"planDataMod.bandwidth",
e.target.value,
)
}
/>
</div>
@@ -774,34 +775,47 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
</Label>
<Input
placeholder="1TB/Month"
value={publicNoteObj.planDataMod.trafficVol}
value={
publicNoteObj.planDataMod
?.trafficVol
}
onChange={(e) =>
setPublicNoteObj((prev) => ({
...prev,
planDataMod: {
...prev.planDataMod,
trafficVol: e.target.value,
},
}))
patchPublicNote(
"planDataMod.trafficVol",
e.target.value,
)
}
/>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Label className="text-xs">
{t("PublicNote.TrafficType")}
</Label>
<Button
type="button"
variant="outline"
className="text-xs px-2 py-0 h-auto bg-gray-200 dark:bg-gray-700"
onClick={() =>
patchPublicNote(
"planDataMod.trafficType",
undefined,
)
}
>
{t("PublicNote.Clear") ?? "Clear"}
</Button>
</div>
<Select
onValueChange={(val) =>
setPublicNoteObj((prev) => ({
...prev,
planDataMod: {
...prev.planDataMod,
trafficType: val,
},
}))
patchPublicNote(
"planDataMod.trafficType",
val,
)
}
defaultValue={
publicNoteObj.planDataMod.trafficType || "2"
value={
publicNoteObj.planDataMod
?.trafficType ?? ""
}
>
<SelectTrigger>
@@ -818,7 +832,11 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
</Select>
{publicNoteErrors["plan.trafficType"] && (
<p className="text-xs text-destructive mt-1">
{publicNoteErrors["plan.trafficType"]}
{
publicNoteErrors[
"plan.trafficType"
]
}
</p>
)}
</div>
@@ -832,16 +850,14 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
</span>
<Switch
checked={
publicNoteObj.planDataMod.IPv4 === "1"
publicNoteObj.planDataMod
?.IPv4 === "1"
}
onCheckedChange={(checked) =>
setPublicNoteObj((prev) => ({
...prev,
planDataMod: {
...prev.planDataMod,
IPv4: checked ? "1" : "0",
},
}))
patchPublicNote(
"planDataMod.IPv4",
checked ? "1" : "0",
)
}
/>
<span className="text-xs">
@@ -864,16 +880,14 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
</span>
<Switch
checked={
publicNoteObj.planDataMod.IPv6 === "1"
publicNoteObj.planDataMod
?.IPv6 === "1"
}
onCheckedChange={(checked) =>
setPublicNoteObj((prev) => ({
...prev,
planDataMod: {
...prev.planDataMod,
IPv6: checked ? "1" : "0",
},
}))
patchPublicNote(
"planDataMod.IPv6",
checked ? "1" : "0",
)
}
/>
<span className="text-xs">
@@ -891,16 +905,18 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
{t("PublicNote.NetworkRoute")}
</Label>
<Input
placeholder={t("PublicNote.CommaSeparated")}
value={publicNoteObj.planDataMod.networkRoute}
placeholder={t(
"PublicNote.CommaSeparated",
)}
value={
publicNoteObj.planDataMod
?.networkRoute ?? ""
}
onChange={(e) =>
setPublicNoteObj((prev) => ({
...prev,
planDataMod: {
...prev.planDataMod,
networkRoute: e.target.value,
},
}))
patchPublicNote(
"planDataMod.networkRoute",
e.target.value,
)
}
/>
</div>
@@ -909,21 +925,25 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
{t("PublicNote.Extra")}
</Label>
<Input
placeholder={t("PublicNote.CommaSeparated")}
value={publicNoteObj.planDataMod.extra}
placeholder={t(
"PublicNote.CommaSeparated",
)}
value={
publicNoteObj.planDataMod?.extra ??
""
}
onChange={(e) =>
setPublicNoteObj((prev) => ({
...prev,
planDataMod: {
...prev.planDataMod,
extra: e.target.value,
},
}))
patchPublicNote(
"planDataMod.extra",
e.target.value,
)
}
/>
</div>
</div>
</div>
</>
)}
</div>
<DialogFooter className="justify-end">
<DialogClose asChild>
+1 -1
View File
@@ -1,7 +1,7 @@
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { forwardRef, ComponentRef, ComponentPropsWithoutRef, HTMLAttributes } from "react"
import { ComponentPropsWithoutRef, ComponentRef, HTMLAttributes, forwardRef } from "react"
const AlertDialog = AlertDialogPrimitive.Root
+1 -1
View File
@@ -1,6 +1,6 @@
import { cn } from "@/lib/utils"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { forwardRef, ComponentRef, ComponentPropsWithoutRef } from "react"
import { ComponentPropsWithoutRef, ComponentRef, forwardRef } from "react"
const Avatar = forwardRef<
ComponentRef<typeof AvatarPrimitive.Root>,
+1 -1
View File
@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { forwardRef, ComponentPropsWithoutRef, ReactNode, ComponentProps } from "react"
import { ComponentProps, ComponentPropsWithoutRef, ReactNode, forwardRef } from "react"
const Breadcrumb = forwardRef<
HTMLElement,
+1 -1
View File
@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils"
import { Slot } from "@radix-ui/react-slot"
import { type VariantProps, cva } from "class-variance-authority"
import { forwardRef, ButtonHTMLAttributes } from "react"
import { ButtonHTMLAttributes, forwardRef } from "react"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+1 -1
View File
@@ -1,7 +1,7 @@
import { Button, buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import { ComponentProps, useRef, useEffect } from "react"
import { ComponentProps, useEffect, useRef } from "react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
function Calendar({
+5 -6
View File
@@ -1,5 +1,5 @@
import { cn } from "@/lib/utils"
import { forwardRef, HTMLAttributes } from "react"
import { HTMLAttributes, forwardRef } from "react"
const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
@@ -30,12 +30,11 @@ const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingEle
)
CardTitle.displayName = "CardTitle"
const CardDescription = forwardRef<
HTMLParagraphElement,
HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
))
),
)
CardDescription.displayName = "CardDescription"
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
+1 -1
View File
@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { forwardRef, ComponentRef, ComponentPropsWithoutRef } from "react"
import { ComponentPropsWithoutRef, ComponentRef, forwardRef } from "react"
const Checkbox = forwardRef<
ComponentRef<typeof CheckboxPrimitive.Root>,
+1 -1
View File
@@ -12,7 +12,7 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { cn } from "@/lib/utils"
import { Check, ChevronDown } from "lucide-react"
import { forwardRef, ButtonHTMLAttributes, useState } from "react"
import { ButtonHTMLAttributes, forwardRef, useState } from "react"
interface ComboboxProps extends ButtonHTMLAttributes<HTMLButtonElement> {
options: {
+1 -1
View File
@@ -3,7 +3,7 @@ import { cn } from "@/lib/utils"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { forwardRef, ComponentRef, ComponentPropsWithoutRef, HTMLAttributes } from "react"
import { ComponentPropsWithoutRef, ComponentRef, HTMLAttributes, forwardRef } from "react"
const Command = forwardRef<
ComponentRef<typeof CommandPrimitive>,
+1 -1
View File
@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { forwardRef, ComponentRef, ComponentPropsWithoutRef, HTMLAttributes } from "react"
import { ComponentPropsWithoutRef, ComponentRef, HTMLAttributes, forwardRef } from "react"
const Dialog = DialogPrimitive.Root
+7 -1
View File
@@ -1,5 +1,11 @@
import { cn } from "@/lib/utils"
import { ComponentProps, forwardRef, ComponentRef, ComponentPropsWithoutRef, HTMLAttributes } from "react"
import {
ComponentProps,
ComponentPropsWithoutRef,
ComponentRef,
HTMLAttributes,
forwardRef,
} from "react"
import { Drawer as DrawerPrimitive } from "vaul"
const Drawer = ({
+1 -1
View File
@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { forwardRef, ComponentRef, ComponentPropsWithoutRef, HTMLAttributes } from "react"
import { ComponentPropsWithoutRef, ComponentRef, HTMLAttributes, forwardRef } from "react"
const DropdownMenu = DropdownMenuPrimitive.Root
+21 -16
View File
@@ -2,7 +2,15 @@ import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import { createContext, useContext, forwardRef, HTMLAttributes, useId, ComponentRef, ComponentPropsWithoutRef } from "react"
import {
ComponentPropsWithoutRef,
ComponentRef,
HTMLAttributes,
createContext,
forwardRef,
useContext,
useId,
} from "react"
import {
Controller,
ControllerProps,
@@ -95,10 +103,8 @@ const FormLabel = forwardRef<
})
FormLabel.displayName = "FormLabel"
const FormControl = forwardRef<
ComponentRef<typeof Slot>,
ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const FormControl = forwardRef<ComponentRef<typeof Slot>, ComponentPropsWithoutRef<typeof Slot>>(
({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
@@ -112,13 +118,12 @@ const FormControl = forwardRef<
{...props}
/>
)
})
},
)
FormControl.displayName = "FormControl"
const FormDescription = forwardRef<
HTMLParagraphElement,
HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const FormDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
@@ -129,13 +134,12 @@ const FormDescription = forwardRef<
{...props}
/>
)
})
},
)
FormDescription.displayName = "FormDescription"
const FormMessage = forwardRef<
HTMLParagraphElement,
HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const FormMessage = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
@@ -153,7 +157,8 @@ const FormMessage = forwardRef<
{body}
</p>
)
})
},
)
FormMessage.displayName = "FormMessage"
export {
+3 -5
View File
@@ -1,10 +1,9 @@
import { cn } from "@/lib/utils"
import { forwardRef, InputHTMLAttributes } from "react"
import { InputHTMLAttributes, forwardRef } from "react"
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
const Input = forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
@@ -16,8 +15,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
{...props}
/>
)
},
)
})
Input.displayName = "Input"
export { Input }
+1 -1
View File
@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils"
import * as LabelPrimitive from "@radix-ui/react-label"
import { type VariantProps, cva } from "class-variance-authority"
import { forwardRef, ComponentRef, ComponentPropsWithoutRef } from "react"
import { ComponentPropsWithoutRef, ComponentRef, forwardRef } from "react"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
+1 -1
View File
@@ -2,7 +2,7 @@ import { cn } from "@/lib/utils"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { forwardRef, ComponentRef, ComponentPropsWithoutRef } from "react"
import { ComponentPropsWithoutRef, ComponentRef, forwardRef } from "react"
const NavigationMenu = forwardRef<
ComponentRef<typeof NavigationMenuPrimitive.Root>,
+1 -4
View File
@@ -45,10 +45,7 @@ const PaginationLink = ({ className, isActive, size = "icon", ...props }: Pagina
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: ComponentProps<typeof PaginationLink>) => (
const PaginationPrevious = ({ className, ...props }: ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
+1 -1
View File
@@ -1,6 +1,6 @@
import { cn } from "@/lib/utils"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { forwardRef, ComponentRef, ComponentPropsWithoutRef } from "react"
import { ComponentPropsWithoutRef, ComponentRef, forwardRef } from "react"
const Popover = PopoverPrimitive.Root
+1 -1
View File
@@ -1,6 +1,6 @@
import { cn } from "@/lib/utils"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { forwardRef, ComponentRef, ComponentPropsWithoutRef } from "react"
import { ComponentPropsWithoutRef, ComponentRef, forwardRef } from "react"
const ScrollArea = forwardRef<
ComponentRef<typeof ScrollAreaPrimitive.Root>,
+1 -1
View File
@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { forwardRef, ComponentRef, ComponentPropsWithoutRef } from "react"
import { ComponentPropsWithoutRef, ComponentRef, forwardRef } from "react"
const Select = SelectPrimitive.Root
+1 -1
View File
@@ -1,6 +1,6 @@
import { cn } from "@/lib/utils"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { forwardRef, ComponentRef, ComponentPropsWithoutRef } from "react"
import { ComponentPropsWithoutRef, ComponentRef, forwardRef } from "react"
const Separator = forwardRef<
ComponentRef<typeof SeparatorPrimitive.Root>,
+1 -1
View File
@@ -1,6 +1,6 @@
import { cn } from "@/lib/utils"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { forwardRef, ComponentRef, ComponentPropsWithoutRef } from "react"
import { ComponentPropsWithoutRef, ComponentRef, forwardRef } from "react"
const Switch = forwardRef<
ComponentRef<typeof SwitchPrimitives.Root>,
+30 -32
View File
@@ -1,5 +1,5 @@
import { cn } from "@/lib/utils"
import { forwardRef, HTMLAttributes, ThHTMLAttributes, TdHTMLAttributes } from "react"
import { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes, forwardRef } from "react"
const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
@@ -14,32 +14,29 @@ const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(
)
Table.displayName = "Table"
const TableHeader = forwardRef<
HTMLTableSectionElement,
HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
const TableHeader = forwardRef<HTMLTableSectionElement, HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
),
)
TableHeader.displayName = "TableHeader"
const TableBody = forwardRef<
HTMLTableSectionElement,
HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
const TableBody = forwardRef<HTMLTableSectionElement, HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
))
),
)
TableBody.displayName = "TableBody"
const TableFooter = forwardRef<
HTMLTableSectionElement,
HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
const TableFooter = forwardRef<HTMLTableSectionElement, HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
))
),
)
TableFooter.displayName = "TableFooter"
const TableRow = forwardRef<HTMLTableRowElement, HTMLAttributes<HTMLTableRowElement>>(
@@ -56,10 +53,8 @@ const TableRow = forwardRef<HTMLTableRowElement, HTMLAttributes<HTMLTableRowElem
)
TableRow.displayName = "TableRow"
const TableHead = forwardRef<
HTMLTableCellElement,
ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
const TableHead = forwardRef<HTMLTableCellElement, ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
@@ -68,27 +63,30 @@ const TableHead = forwardRef<
)}
{...props}
/>
))
),
)
TableHead.displayName = "TableHead"
const TableCell = forwardRef<
HTMLTableCellElement,
TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
const TableCell = forwardRef<HTMLTableCellElement, TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
),
)
TableCell.displayName = "TableCell"
const TableCaption = forwardRef<
HTMLTableCaptionElement,
HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
))
const TableCaption = forwardRef<HTMLTableCaptionElement, HTMLAttributes<HTMLTableCaptionElement>>(
({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
),
)
TableCaption.displayName = "TableCaption"
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
+1 -1
View File
@@ -1,6 +1,6 @@
import { cn } from "@/lib/utils"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { forwardRef, ComponentRef, ComponentPropsWithoutRef } from "react"
import { ComponentPropsWithoutRef, ComponentRef, forwardRef } from "react"
const Tabs = TabsPrimitive.Root
+1 -1
View File
@@ -1,5 +1,5 @@
import { cn } from "@/lib/utils"
import { forwardRef, ComponentProps } from "react"
import { ComponentProps, forwardRef } from "react"
const Textarea = forwardRef<HTMLTextAreaElement, ComponentProps<"textarea">>(
({ className, ...props }, ref) => {
+1 -1
View File
@@ -15,7 +15,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { formatPath } from "@/lib/utils"
import { Dispatch, SetStateAction, FC, useState, Fragment } from "react"
import { Dispatch, FC, Fragment, SetStateAction, useState } from "react"
const ITEMS_TO_DISPLAY = 3
+1 -1
View File
@@ -38,7 +38,7 @@ import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
import { type VariantProps, cva } from "class-variance-authority"
import { CheckIcon, ChevronDown, WandSparkles, XIcon } from "lucide-react"
import { useState, forwardRef, KeyboardEvent } from "react"
import { KeyboardEvent, forwardRef, useState } from "react"
/**
* Variants for the multi-select component to handle different styles.
+12 -6
View File
@@ -2,7 +2,14 @@ import { cn } from "@/lib/utils"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { type VariantProps, cva } from "class-variance-authority"
import { X } from "lucide-react"
import { ComponentPropsWithoutRef, Dispatch, SetStateAction, forwardRef, ComponentRef, HTMLAttributes } from "react"
import {
ComponentPropsWithoutRef,
ComponentRef,
Dispatch,
HTMLAttributes,
SetStateAction,
forwardRef,
} from "react"
const Sheet = SheetPrimitive.Root
@@ -35,10 +42,8 @@ interface SheetContentProps
setOpen: Dispatch<SetStateAction<boolean>>
}
const SheetContent = forwardRef<
ComponentRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, setOpen, ...props }, ref) => (
const SheetContent = forwardRef<ComponentRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = "right", className, children, setOpen, ...props }, ref) => (
<SheetPortal>
<SheetPrimitive.Content
ref={ref}
@@ -57,7 +62,8 @@ const SheetContent = forwardRef<
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
),
)
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({ className, ...props }: HTMLAttributes<HTMLDivElement>) => (
+1 -1
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"
import { useEffect, useState } from "react"
export function useMediaQuery(query: string) {
const [value, setValue] = useState(false)
+182
View File
@@ -0,0 +1,182 @@
import { z } from "zod"
import i18n from "./i18n"
/**
* Zod schema for PublicNote
* Conventions:
* - All fields are strings and may be empty
* - IPv4/IPv6/autoRenewal must be "0" or "1"
* - cycle is one of Day/Week/Month/Year
* - Date fields can be empty, ISO-like, or the special value "0000-00-00T23:59:59+08:00"
*/
export const PublicNoteSchema = z.object({
billingDataMod: z
.object({
startDate: z.string().optional(),
endDate: z.string().optional(),
autoRenewal: z.string().optional(),
cycle: z.string().optional(),
amount: z.string().optional(),
})
.optional(),
planDataMod: z
.object({
bandwidth: z.string().optional(),
trafficVol: z.string().optional(),
trafficType: z.string().optional(),
IPv4: z.string().optional(),
IPv6: z.string().optional(),
networkRoute: z.string().optional(),
extra: z.string().optional(),
})
.optional(),
})
export type PublicNote = z.infer<typeof PublicNoteSchema>
export const defaultPublicNote: PublicNote = {}
export const isValidISOLike = (v: string) => {
if (!v) return true
if (v === "0000-00-00T23:59:59+08:00") return true
const d = new Date(v)
return !isNaN(d.getTime())
}
export const normalizeISO = (v?: string) => {
if (!v) return undefined
if (v === "0000-00-00T23:59:59+08:00") return v
const date = new Date(v)
return isNaN(date.getTime()) ? v : date.toISOString()
}
/**
* Parse a string into PublicNote; return the default object if not valid JSON or validation fails.
*/
export const parsePublicNote = (s?: string): PublicNote => {
if (!s) return defaultPublicNote
try {
const obj = JSON.parse(s)
const parsed = PublicNoteSchema.safeParse(obj)
if (parsed.success) {
return parsed.data
}
return defaultPublicNote
} catch {
return defaultPublicNote
}
}
export const validatePublicNote = (pn: PublicNote) => {
const errors: Partial<Record<string, string>> = {}
// Structural and enum validations
if (pn.billingDataMod?.autoRenewal && !/^(0|1)$/.test(pn.billingDataMod.autoRenewal)) {
errors["billing.autoRenewal"] = i18n.t("Validation.MustBe0Or1")
}
if (pn.billingDataMod?.cycle && !/^(Day|Week|Month|Year)$/i.test(pn.billingDataMod.cycle)) {
errors["billing.cycle"] = i18n.t("Validation.MustBeDayWeekMonthYear")
}
if (pn.planDataMod?.trafficType && !/^(1|2)$/.test(pn.planDataMod.trafficType)) {
errors["plan.trafficType"] = i18n.t("Validation.MustBe1Or2")
}
if (pn.planDataMod?.IPv4 !== undefined && !/^(0|1)$/.test(pn.planDataMod.IPv4)) {
errors["plan.IPv4"] = i18n.t("Validation.MustBe0Or1")
}
if (pn.planDataMod?.IPv6 !== undefined && !/^(0|1)$/.test(pn.planDataMod.IPv6)) {
errors["plan.IPv6"] = i18n.t("Validation.MustBe0Or1")
}
// Date validity checks
if (pn.billingDataMod?.startDate && !isValidISOLike(pn.billingDataMod.startDate)) {
errors["billing.startDate"] = i18n.t("Validation.InvalidDate")
}
if (pn.billingDataMod?.endDate && !isValidISOLike(pn.billingDataMod.endDate)) {
errors["billing.endDate"] = i18n.t("Validation.InvalidDate")
}
return { errors, valid: Object.keys(errors).length === 0 }
}
/**
* Detect default mode from string: JSON matching schema -> "structured"; otherwise "raw".
*/
export const detectPublicNoteMode = (s?: string): "structured" | "raw" => {
if (!s) return "raw"
try {
const obj = JSON.parse(s)
const parsed = PublicNoteSchema.strict().safeParse(obj)
return parsed.success ? "structured" : "raw"
} catch {
return "raw"
}
}
/**
* Immutable patch by path, for use in component wrappers around setPublicNoteObj.
* Example path: "billingDataMod.startDate"
*/
export const applyPublicNotePatch = (
obj: PublicNote,
path: string,
value: string | undefined,
): PublicNote => {
const keys = path.split(".")
const draft: any = structuredClone ? structuredClone(obj) : JSON.parse(JSON.stringify(obj))
let cur: any = draft
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i]
cur[k] = { ...(cur[k] ?? {}) }
cur = cur[k]
}
cur[keys[keys.length - 1]] = value
return draft
}
/**
* Update a date field while preserving time parts: if the previous value is a valid date,
* keep hours/minutes/seconds. Path example: "billingDataMod.startDate" | "billingDataMod.endDate"
*/
export const applyPublicNoteDate = (obj: PublicNote, path: string, date: Date): PublicNote => {
const keys = path.split(".")
const draft: any = structuredClone ? structuredClone(obj) : JSON.parse(JSON.stringify(obj))
// Read previous value to preserve time components
let curRead: any = draft
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i]
curRead = (curRead as any)[k]
if (!curRead) break
}
const leafKey = keys[keys.length - 1]
const prevVal: string | undefined = curRead ? curRead[leafKey] : undefined
const d = new Date(date)
if (prevVal) {
const pd = new Date(prevVal)
if (!isNaN(pd.getTime())) {
d.setHours(pd.getHours(), pd.getMinutes(), pd.getSeconds(), 0)
}
}
// Write back
let curWrite: any = draft
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i]
curWrite[k] = { ...(curWrite[k] ?? {}) }
curWrite = curWrite[k]
}
curWrite[leafKey] = d.toISOString()
return draft
}
/**
* Toggle the special "no expiry" value for endDate.
*/
export const toggleEndNoExpiry = (obj: PublicNote): PublicNote => {
const NO_EXPIRY = "0000-00-00T23:59:59+08:00"
const current = obj.billingDataMod?.endDate
const next = current === NO_EXPIRY ? "" : NO_EXPIRY
return applyPublicNotePatch(obj, "billingDataMod.endDate", next)
}
+17 -4
View File
@@ -195,6 +195,7 @@
"Value": "Value",
"Preview": "Preview",
"PublicNote": {
"Label": "Public Note",
"Billing": "Billing",
"Plan": "Plan",
"StartDate": "Start Date",
@@ -222,10 +223,13 @@
"CancelNoExpiry": "Cancel No Expiry",
"Free": "Free",
"PayAsYouGo": "Pay as you go",
"DropdownHint": "You may need to reselect for dropdown changes to take effect",
"CommaSeparated": "Separate multiple items with commas",
"Has": "Has",
"None": "None"
"None": "None",
"CustomFields": "Custom Fields",
"ClearDate": "Clear Date",
"Clear": "Clear",
"RawText": "Raw Text"
},
"Validation": {
"InvalidDate": "Invalid date",
@@ -233,7 +237,8 @@
"MustBeDayWeekMonthYear": "Must be Day/Week/Month/Year",
"MustBe1Or2": "Must be 1 or 2",
"DigitsOnly": "Digits only",
"InvalidForm": "Invalid form"
"InvalidForm": "Invalid form",
"InvalidJSON": "Invalid JSON"
},
"AlertRules": {
"CoverAllServers": "Monitor all servers",
@@ -241,5 +246,13 @@
"IgnoreHint": "{{server}} ID: true/false",
"IgnoreExample": "e.g., {\"1\": true, \"2\": false}"
},
"Search": "Search..."
"Search": "Search...",
"Format": "Format",
"Formatted": "Formatted",
"Copy": "Copy",
"Paste": "Paste",
"CopiedToClipboard": "Copied to clipboard",
"ClipboardWriteFailed": "Clipboard write failed",
"PastedFromClipboard": "Pasted from clipboard",
"ClipboardReadFailed": "Clipboard read failed"
}
+17 -4
View File
@@ -195,6 +195,7 @@
"Preview": "预览",
"Option": "选项",
"PublicNote": {
"Label": "公开备注",
"Billing": "账单信息",
"Plan": "套餐配置",
"StartDate": "开始时间",
@@ -222,10 +223,13 @@
"CancelNoExpiry": "取消不过期",
"Free": "免费",
"PayAsYouGo": "按量付费",
"DropdownHint": "下拉项需要重新选择才能生效",
"CommaSeparated": "以英文逗号分隔多个",
"Has": "有",
"None": "无"
"None": "无",
"CustomFields": "自定义字段",
"ClearDate": "清除日期",
"Clear": "清除",
"RawText": "原始文本"
},
"Validation": {
"InvalidDate": "无效的日期格式",
@@ -233,7 +237,8 @@
"MustBeDayWeekMonthYear": "必须为 Day/Week/Month/Year",
"MustBe1Or2": "只能为 1 或 2",
"DigitsOnly": "仅允许数字",
"InvalidForm": "表单校验失败"
"InvalidForm": "表单校验失败",
"InvalidJSON": "无效的 JSON"
},
"AlertRules": {
"CoverAllServers": "监控所有服务器",
@@ -241,5 +246,13 @@
"IgnoreHint": "{{server}}ID: true/false",
"IgnoreExample": "例如:{\"1\": true, \"2\": false}"
},
"Search": "搜索..."
"Search": "搜索...",
"Format": "格式化",
"Formatted": "已格式化",
"Copy": "复制",
"Paste": "粘贴",
"CopiedToClipboard": "已复制到剪贴板",
"ClipboardWriteFailed": "无法写入剪贴板",
"PastedFromClipboard": "已从剪贴板粘贴",
"ClipboardReadFailed": "无法读取剪贴板"
}
+14 -17
View File
@@ -1,30 +1,27 @@
import { createRoot } from "react-dom/client"
import { RouterProvider, createBrowserRouter } from "react-router-dom"
import "./index.css"
import "./lib/i18n"
import { TerminalPage } from "./components/terminal"
import ErrorPage from "./error-page"
import { AuthProvider } from "./hooks/useAuth"
import { NotificationProvider } from "./hooks/useNotfication"
import { ServerProvider } from "./hooks/useServer"
import Root from "./routes/root"
import ErrorPage from "./error-page"
import ProtectedRoute from "./routes/protect"
import CronPage from "./routes/cron"
import LoginPage from "./routes/login"
import ServerPage from "./routes/server"
import ServicePage from "./routes/service"
import { TerminalPage } from "./components/terminal"
import DDNSPage from "./routes/ddns"
import NATPage from "./routes/nat"
import NotificationGroupPage from "./routes/notification-group"
import ServerGroupPage from "./routes/server-group"
import "./index.css"
import "./lib/i18n"
import AlertRulePage from "./routes/alert-rule"
import CronPage from "./routes/cron"
import DDNSPage from "./routes/ddns"
import LoginPage from "./routes/login"
import NATPage from "./routes/nat"
import NotificationPage from "./routes/notification"
import NotificationGroupPage from "./routes/notification-group"
import OnlineUserPage from "./routes/online-user"
import ProfilePage from "./routes/profile"
import ProtectedRoute from "./routes/protect"
import Root from "./routes/root"
import ServerPage from "./routes/server"
import ServerGroupPage from "./routes/server-group"
import ServicePage from "./routes/service"
import SettingsPage from "./routes/settings"
import UserPage from "./routes/user"
import WAFPage from "./routes/waf"