fix(frontend): stabilize multiselect behavior and UI spacing (#154)

* fix(frontend): left-align announcement logo in homepage banner

* fix(frontend): keep multiselect panels floating and preserve summaries

* fix(frontend): rebalance config card header spacing

* fix(frontend): normalize formatting and fix multiselect modal behavior

* fix(frontend): restore config divider spacing on desktop
This commit is contained in:
sagit
2026-02-19 20:50:12 +08:00
committed by GitHub
parent 18c2da7c7e
commit 2337c6e488
52 changed files with 743 additions and 301 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Link } from "@/shadcn-bridge/heroui/link"; import { Link } from "@/shadcn-bridge/heroui/link";
import { import {
Navbar as HeroUINavbar, Navbar as HeroUINavbar,
NavbarBrand, NavbarBrand,
NavbarContent, NavbarContent,
} from "@/shadcn-bridge/heroui/navbar"; } from "@/shadcn-bridge/heroui/navbar";
import { useNavigate } from "react-router-dom";
import { Logo } from "@/components/icons"; import { Logo } from "@/components/icons";
import { siteConfig, getCachedConfig } from "@/config/site"; import { siteConfig, getCachedConfig } from "@/config/site";
import { useWebViewMode } from "@/hooks/useWebViewMode"; import { useWebViewMode } from "@/hooks/useWebViewMode";
@@ -1,4 +1,5 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useTheme } from "@/shadcn-bridge/heroui/use-theme"; import { useTheme } from "@/shadcn-bridge/heroui/use-theme";
interface ThemeProviderProps { interface ThemeProviderProps {
+15 -5
View File
@@ -9,9 +9,12 @@ const alertVariants = cva(
variants: { variants: {
variant: { variant: {
default: "border-default-200 bg-default-50/70 text-foreground", default: "border-default-200 bg-default-50/70 text-foreground",
destructive: "border-danger-200 bg-danger-50 text-danger-700 dark:text-danger-300", destructive:
success: "border-success-200 bg-success-50 text-success-700 dark:text-success-300", "border-danger-200 bg-danger-50 text-danger-700 dark:text-danger-300",
warning: "border-warning-200 bg-warning-50 text-warning-700 dark:text-warning-300", success:
"border-success-200 bg-success-50 text-success-700 dark:text-success-300",
warning:
"border-warning-200 bg-warning-50 text-warning-700 dark:text-warning-300",
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -45,9 +48,16 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"h5">) {
); );
} }
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return ( return (
<div className={cn("text-sm opacity-90", className)} data-slot="alert-description" {...props} /> <div
className={cn("text-sm opacity-90", className)}
data-slot="alert-description"
{...props}
/>
); );
} }
+9 -5
View File
@@ -11,11 +11,15 @@ const buttonVariants = cva(
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-danger text-white hover:bg-danger/90", destructive: "bg-danger text-white hover:bg-danger/90",
outline: "border border-input bg-background hover:bg-default-100 hover:text-foreground dark:hover:bg-default-200/40", outline:
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", "border border-input bg-background hover:bg-default-100 hover:text-foreground dark:hover:bg-default-200/40",
ghost: "hover:bg-default-100 hover:text-foreground dark:hover:bg-default-200/40", secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-default-100 hover:text-foreground dark:hover:bg-default-200/40",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
light: "bg-transparent hover:bg-default-100 dark:hover:bg-default-200/40", light:
"bg-transparent hover:bg-default-100 dark:hover:bg-default-200/40",
flat: "bg-default-100 text-foreground hover:bg-default-200 dark:bg-default-100/40", flat: "bg-default-100 text-foreground hover:bg-default-200 dark:bg-default-100/40",
}, },
size: { size: {
@@ -43,9 +47,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
return ( return (
<Comp <Comp
ref={ref}
className={cn(buttonVariants({ className, size, variant }))} className={cn(buttonVariants({ className, size, variant }))}
data-slot="button" data-slot="button"
ref={ref}
{...props} {...props}
/> />
); );
+17 -3
View File
@@ -28,7 +28,10 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
function CardTitle({ className, ...props }: React.ComponentProps<"h3">) { function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
return ( return (
<h3 <h3
className={cn("text-lg font-semibold leading-none tracking-tight", className)} className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
data-slot="card-title" data-slot="card-title"
{...props} {...props}
/> />
@@ -47,7 +50,11 @@ function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div className={cn("p-6 pt-0", className)} data-slot="card-content" {...props} /> <div
className={cn("p-6 pt-0", className)}
data-slot="card-content"
{...props}
/>
); );
} }
@@ -61,4 +68,11 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
); );
} }
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }; export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};
+4 -1
View File
@@ -4,7 +4,10 @@ import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) { function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return ( return (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
className={cn( className={cn(
+33 -12
View File
@@ -4,23 +4,34 @@ import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) { function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />; return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) { function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />; return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) { function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />; return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) { function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />; return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) { function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return ( return (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
className={cn( className={cn(
@@ -54,9 +65,7 @@ function DialogContent({
> >
{children} {children}
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none">
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none"
>
<XIcon className="h-4 w-4" /> <XIcon className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
@@ -69,7 +78,10 @@ function DialogContent({
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
data-slot="dialog-header" data-slot="dialog-header"
{...props} {...props}
/> />
@@ -79,17 +91,26 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
data-slot="dialog-footer" data-slot="dialog-footer"
{...props} {...props}
/> />
); );
} }
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) { function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
className={cn("text-lg font-semibold leading-none tracking-tight", className)} className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
data-slot="dialog-title" data-slot="dialog-title"
{...props} {...props}
/> />
@@ -4,32 +4,54 @@ import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />; return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
} }
function DropdownMenuTrigger({ function DropdownMenuTrigger({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />; return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
} }
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { function DropdownMenuGroup({
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />; ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
} }
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { function DropdownMenuPortal({
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />; ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
} }
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />; return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
} }
function DropdownMenuRadioGroup({ function DropdownMenuRadioGroup({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />; return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
} }
function DropdownMenuSubTrigger({ function DropdownMenuSubTrigger({
@@ -171,7 +193,11 @@ function DropdownMenuLabel({
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)} className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
data-slot="dropdown-menu-label" data-slot="dropdown-menu-label"
{...props} {...props}
/> />
@@ -191,10 +217,16 @@ function DropdownMenuSeparator({
); );
} }
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return ( return (
<span <span
className={cn("ml-auto text-xs tracking-widest text-default-500", className)} className={cn(
"ml-auto text-xs tracking-widest text-default-500",
className,
)}
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
{...props} {...props}
/> />
+4 -1
View File
@@ -3,7 +3,10 @@ import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) { function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return ( return (
<LabelPrimitive.Root <LabelPrimitive.Root
className={cn( className={cn(
+8 -2
View File
@@ -13,13 +13,19 @@ function Progress({
}) { }) {
return ( return (
<ProgressPrimitive.Root <ProgressPrimitive.Root
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-default-200", className)} className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-default-200",
className,
)}
data-slot="progress" data-slot="progress"
value={value} value={value}
{...props} {...props}
> >
<ProgressPrimitive.Indicator <ProgressPrimitive.Indicator
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)} className={cn(
"h-full w-full flex-1 bg-primary transition-all",
indicatorClassName,
)}
data-slot="progress-indicator" data-slot="progress-indicator"
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }} style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
/> />
@@ -4,7 +4,10 @@ import { CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function RadioGroup({ className, ...props }: React.ComponentProps<typeof RadioGroupPrimitive.Root>) { function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return ( return (
<RadioGroupPrimitive.Root <RadioGroupPrimitive.Root
className={cn("grid gap-2", className)} className={cn("grid gap-2", className)}
@@ -27,7 +30,10 @@ function RadioGroupItem({
data-slot="radio-group-item" data-slot="radio-group-item"
{...props} {...props}
> >
<RadioGroupPrimitive.Indicator className="flex items-center justify-center" data-slot="radio-group-indicator"> <RadioGroupPrimitive.Indicator
className="flex items-center justify-center"
data-slot="radio-group-indicator"
>
<CircleIcon className="h-2.5 w-2.5 fill-current text-current" /> <CircleIcon className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator> </RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item> </RadioGroupPrimitive.Item>
+21 -6
View File
@@ -4,15 +4,21 @@ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) { function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />; return <SelectPrimitive.Root data-slot="select" {...props} />;
} }
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) { function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />; return <SelectPrimitive.Group data-slot="select-group" {...props} />;
} }
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) { function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />; return <SelectPrimitive.Value data-slot="select-value" {...props} />;
} }
@@ -44,7 +50,10 @@ function SelectScrollUpButton({
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) { }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return ( return (
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
className={cn("flex cursor-default items-center justify-center py-1", className)} className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
{...props} {...props}
> >
@@ -59,7 +68,10 @@ function SelectScrollDownButton({
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) { }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return ( return (
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
className={cn("flex cursor-default items-center justify-center py-1", className)} className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
{...props} {...props}
> >
@@ -103,7 +115,10 @@ function SelectContent({
); );
} }
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) { function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return ( return (
<SelectPrimitive.Label <SelectPrimitive.Label
className={cn("px-2 py-1.5 text-sm font-semibold", className)} className={cn("px-2 py-1.5 text-sm font-semibold", className)}
+4 -1
View File
@@ -3,7 +3,10 @@ import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) { function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return ( return (
<SwitchPrimitive.Root <SwitchPrimitive.Root
className={cn( className={cn(
+26 -6
View File
@@ -5,7 +5,11 @@ import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) { function Table({ className, ...props }: React.ComponentProps<"table">) {
return ( return (
<div className="relative w-full overflow-auto" data-slot="table-wrapper"> <div className="relative w-full overflow-auto" data-slot="table-wrapper">
<table className={cn("w-full caption-bottom text-sm", className)} data-slot="table" {...props} /> <table
className={cn("w-full caption-bottom text-sm", className)}
data-slot="table"
{...props}
/>
</div> </div>
); );
} }
@@ -33,7 +37,10 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return ( return (
<tfoot <tfoot
className={cn("border-t bg-default-50/70 font-medium [&>tr]:last:border-b-0", className)} className={cn(
"border-t bg-default-50/70 font-medium [&>tr]:last:border-b-0",
className,
)}
data-slot="table-footer" data-slot="table-footer"
{...props} {...props}
/> />
@@ -43,7 +50,10 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
function TableRow({ className, ...props }: React.ComponentProps<"tr">) { function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return ( return (
<tr <tr
className={cn("border-b transition-colors hover:bg-default-50/50", className)} className={cn(
"border-b transition-colors hover:bg-default-50/50",
className,
)}
data-slot="table-row" data-slot="table-row"
{...props} {...props}
/> />
@@ -66,16 +76,26 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
function TableCell({ className, ...props }: React.ComponentProps<"td">) { function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return ( return (
<td <td
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0", className)} className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0",
className,
)}
data-slot="table-cell" data-slot="table-cell"
{...props} {...props}
/> />
); );
} }
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) { function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return ( return (
<caption className={cn("mt-4 text-sm text-default-500", className)} data-slot="table-caption" {...props} /> <caption
className={cn("mt-4 text-sm text-default-500", className)}
data-slot="table-caption"
{...props}
/>
); );
} }
+21 -5
View File
@@ -3,13 +3,23 @@ import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) { function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return ( return (
<TabsPrimitive.Root className={cn("flex flex-col gap-2", className)} data-slot="tabs" {...props} /> <TabsPrimitive.Root
className={cn("flex flex-col gap-2", className)}
data-slot="tabs"
{...props}
/>
); );
} }
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) { function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return ( return (
<TabsPrimitive.List <TabsPrimitive.List
className={cn( className={cn(
@@ -22,7 +32,10 @@ function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimi
); );
} }
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) { function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return ( return (
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
className={cn( className={cn(
@@ -35,7 +48,10 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
); );
} }
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) { function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return ( return (
<TabsPrimitive.Content <TabsPrimitive.Content
className={cn("outline-none", className)} className={cn("outline-none", className)}
+2 -2
View File
@@ -1,5 +1,7 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { toast } from "react-hot-toast";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { import {
Dropdown, Dropdown,
@@ -16,8 +18,6 @@ import {
useDisclosure, useDisclosure,
} from "@/shadcn-bridge/heroui/modal"; } from "@/shadcn-bridge/heroui/modal";
import { Input } from "@/shadcn-bridge/heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
import { toast } from "react-hot-toast";
import { Logo } from "@/components/icons"; import { Logo } from "@/components/icons";
import { updatePassword } from "@/api"; import { updatePassword } from "@/api";
import { safeLogout } from "@/utils/logout"; import { safeLogout } from "@/utils/logout";
+1 -1
View File
@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Logo } from "@/components/icons"; import { Logo } from "@/components/icons";
import { siteConfig } from "@/config/site"; import { siteConfig } from "@/config/site";
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange"; import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
+3 -3
View File
@@ -1,10 +1,10 @@
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { title } from "@/components/primitives"; import { title } from "@/components/primitives";
import { updatePassword } from "@/api"; import { updatePassword } from "@/api";
import DefaultLayout from "@/layouts/default"; import DefaultLayout from "@/layouts/default";
+6 -6
View File
@@ -585,7 +585,7 @@ export default function ConfigPage() {
</div> </div>
<Card className="shadow-md"> <Card className="shadow-md">
<CardHeader className="pb-4"> <CardHeader className="pb-6">
<div className="flex justify-between items-center w-full"> <div className="flex justify-between items-center w-full">
<div> <div>
<h2 className="text-xl font-semibold">基本设置</h2> <h2 className="text-xl font-semibold">基本设置</h2>
@@ -609,7 +609,7 @@ export default function ConfigPage() {
<Divider /> <Divider />
<CardBody className="space-y-6 pt-6"> <CardBody className="space-y-6 pt-8 md:pt-8">
{CONFIG_ITEMS.map((item, index) => { {CONFIG_ITEMS.map((item, index) => {
// 检查配置项是否应该显示 // 检查配置项是否应该显示
if (!shouldShowItem(item)) { if (!shouldShowItem(item)) {
@@ -660,7 +660,7 @@ export default function ConfigPage() {
)} )}
<Card className="mt-6 shadow-md"> <Card className="mt-6 shadow-md">
<CardHeader className="pb-4"> <CardHeader className="pb-6">
<div className="flex justify-between items-center w-full"> <div className="flex justify-between items-center w-full">
<div> <div>
<h2 className="text-xl font-semibold">公告管理</h2> <h2 className="text-xl font-semibold">公告管理</h2>
@@ -673,7 +673,7 @@ export default function ConfigPage() {
<Divider /> <Divider />
<CardBody className="space-y-4 pt-6"> <CardBody className="space-y-4 pt-8 md:pt-8">
{announcementLoading ? ( {announcementLoading ? (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">
<Spinner size="lg" /> <Spinner size="lg" />
@@ -727,7 +727,7 @@ export default function ConfigPage() {
{/* 备份与恢复 */} {/* 备份与恢复 */}
<Card className="mt-6 shadow-md"> <Card className="mt-6 shadow-md">
<CardHeader className="pb-4"> <CardHeader className="pb-6">
<div className="flex justify-between items-center w-full"> <div className="flex justify-between items-center w-full">
<div> <div>
<h2 className="text-xl font-semibold">数据备份与恢复</h2> <h2 className="text-xl font-semibold">数据备份与恢复</h2>
@@ -740,7 +740,7 @@ export default function ConfigPage() {
<Divider /> <Divider />
<CardBody className="space-y-6 pt-6"> <CardBody className="space-y-6 pt-8 md:pt-8">
{/* 导出部分 */} {/* 导出部分 */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-medium">导出数据</h3> <h3 className="text-lg font-medium">导出数据</h3>
+8 -3
View File
@@ -1,9 +1,14 @@
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Modal, ModalContent, ModalHeader, ModalBody } from "@/shadcn-bridge/heroui/modal";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
} from "@/shadcn-bridge/heroui/modal";
import { PageEmptyState, PageLoadingState } from "@/components/page-state"; import { PageEmptyState, PageLoadingState } from "@/components/page-state";
import { AnnouncementBanner } from "@/pages/dashboard/components/announcement-banner"; import { AnnouncementBanner } from "@/pages/dashboard/components/announcement-banner";
import { FlowChartCard } from "@/pages/dashboard/components/flow-chart-card"; import { FlowChartCard } from "@/pages/dashboard/components/flow-chart-card";
@@ -16,7 +16,7 @@ export const AnnouncementBanner = ({
return ( return (
<Card className="mb-4 lg:mb-6 border border-blue-200 dark:border-blue-500/30 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-500/10 dark:to-purple-500/10"> <Card className="mb-4 lg:mb-6 border border-blue-200 dark:border-blue-500/30 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-500/10 dark:to-purple-500/10">
<CardBody className="p-4"> <CardBody className="p-4">
<div className="flex items-start gap-3"> <div className="flex items-center justify-start gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0"> <div className="p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -1,4 +1,3 @@
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { import {
CartesianGrid, CartesianGrid,
Line, Line,
@@ -9,6 +8,7 @@ import {
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { PageEmptyState } from "@/components/page-state"; import { PageEmptyState } from "@/components/page-state";
interface FlowChartPoint { interface FlowChartPoint {
@@ -1,3 +1,5 @@
import type { ForwardApiItem } from "@/api/types";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
@@ -6,7 +8,6 @@ import {
getUserPackageInfo, getUserPackageInfo,
type AnnouncementData, type AnnouncementData,
} from "@/api"; } from "@/api";
import type { ForwardApiItem } from "@/api/types";
import { getAdminFlag } from "@/utils/session"; import { getAdminFlag } from "@/utils/session";
export interface DashboardUserInfo { export interface DashboardUserInfo {
@@ -180,9 +181,13 @@ export const useDashboardData = (): DashboardDataState => {
); );
const [userTunnels, setUserTunnels] = useState<DashboardUserTunnel[]>([]); const [userTunnels, setUserTunnels] = useState<DashboardUserTunnel[]>([]);
const [forwardList, setForwardList] = useState<DashboardForward[]>([]); const [forwardList, setForwardList] = useState<DashboardForward[]>([]);
const [statisticsFlows, setStatisticsFlows] = useState<DashboardStatisticsFlow[]>([]); const [statisticsFlows, setStatisticsFlows] = useState<
DashboardStatisticsFlow[]
>([]);
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(null); const [announcement, setAnnouncement] = useState<AnnouncementData | null>(
null,
);
useEffect(() => { useEffect(() => {
const loadAnnouncement = async () => { const loadAnnouncement = async () => {
@@ -214,7 +219,10 @@ export const useDashboardData = (): DashboardDataState => {
setForwardList(normalizedForwards); setForwardList(normalizedForwards);
setStatisticsFlows(data.statisticsFlows || []); setStatisticsFlows(data.statisticsFlows || []);
checkExpirationNotifications(data.userInfo, normalizedTunnelPermissions); checkExpirationNotifications(
data.userInfo,
normalizedTunnelPermissions,
);
} else { } else {
toast.error(res.msg || "获取套餐信息失败"); toast.error(res.msg || "获取套餐信息失败");
} }
+30 -20
View File
@@ -1,22 +1,4 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Textarea } from "@/shadcn-bridge/heroui/input";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
} from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Switch } from "@/shadcn-bridge/heroui/switch";
import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { import {
DndContext, DndContext,
@@ -37,6 +19,24 @@ import {
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Textarea } from "@/shadcn-bridge/heroui/input";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
} from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Switch } from "@/shadcn-bridge/heroui/switch";
import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { import {
createForward, createForward,
getForwardList, getForwardList,
@@ -1604,10 +1604,20 @@ export default function ForwardPage() {
<span className="text-sm text-default-600 shrink-0"> <span className="text-sm text-default-600 shrink-0">
已选择 {selectedIds.size} 项 已选择 {selectedIds.size} 项
</span> </span>
<Button color="primary" size="sm" variant="flat" onPress={selectAll}> <Button
color="primary"
size="sm"
variant="flat"
onPress={selectAll}
>
全选 全选
</Button> </Button>
<Button color="secondary" size="sm" variant="flat" onPress={deselectAll}> <Button
color="secondary"
size="sm"
variant="flat"
onPress={deselectAll}
>
清空 清空
</Button> </Button>
<Button <Button
+34 -14
View File
@@ -1,4 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
@@ -21,8 +23,6 @@ import {
} from "@/shadcn-bridge/heroui/table"; } from "@/shadcn-bridge/heroui/table";
import { Chip } from "@/shadcn-bridge/heroui/chip"; import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Spinner } from "@/shadcn-bridge/heroui/spinner"; import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import toast from "react-hot-toast";
import { import {
assignGroupPermission, assignGroupPermission,
assignTunnelsToGroup, assignTunnelsToGroup,
@@ -173,6 +173,22 @@ export default function GroupPage() {
return map; return map;
}, [users]); }, [users]);
const selectedTunnelSummary = useMemo(() => {
const value = Array.from(selectedTunnelKeys)
.map((id) => tunnelNameMap.get(Number(id)) || id)
.join("、");
return value || "无";
}, [selectedTunnelKeys, tunnelNameMap]);
const selectedUserSummary = useMemo(() => {
const value = Array.from(selectedUserKeys)
.map((id) => userNameMap.get(Number(id)) || id)
.join("、");
return value || "无";
}, [selectedUserKeys, userNameMap]);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -765,8 +781,10 @@ export default function GroupPage() {
> >
<ModalContent> <ModalContent>
<ModalHeader>分配隧道 - {assignTunnelGroup?.name}</ModalHeader> <ModalHeader>分配隧道 - {assignTunnelGroup?.name}</ModalHeader>
<ModalBody> <ModalBody className="min-w-0">
<Select <Select
className="min-w-0"
classNames={{ trigger: "max-w-full" }}
items={tunnels} items={tunnels}
label="选择隧道" label="选择隧道"
selectedKeys={selectedTunnelKeys} selectedKeys={selectedTunnelKeys}
@@ -779,11 +797,11 @@ export default function GroupPage() {
> >
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>} {(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select> </Select>
<p className="text-xs text-default-500 break-all whitespace-normal"> <p
当前已选: className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
{Array.from(selectedTunnelKeys) title={`当前已选:${selectedTunnelSummary}`}
.map((id) => tunnelNameMap.get(Number(id)) || id) >
.join("、") || "无"} 当前已选:{selectedTunnelSummary}
</p> </p>
<p className="text-xs text-default-500"> <p className="text-xs text-default-500">
不选择任何隧道并保存将清空该分组成员。 不选择任何隧道并保存将清空该分组成员。
@@ -810,8 +828,10 @@ export default function GroupPage() {
> >
<ModalContent> <ModalContent>
<ModalHeader>分配用户 - {assignUserGroup?.name}</ModalHeader> <ModalHeader>分配用户 - {assignUserGroup?.name}</ModalHeader>
<ModalBody> <ModalBody className="min-w-0">
<Select <Select
className="min-w-0"
classNames={{ trigger: "max-w-full" }}
items={users} items={users}
label="选择用户" label="选择用户"
selectedKeys={selectedUserKeys} selectedKeys={selectedUserKeys}
@@ -824,11 +844,11 @@ export default function GroupPage() {
> >
{(item) => <SelectItem key={item.id}>{item.user}</SelectItem>} {(item) => <SelectItem key={item.id}>{item.user}</SelectItem>}
</Select> </Select>
<p className="text-xs text-default-500 break-all whitespace-normal"> <p
当前已选: className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
{Array.from(selectedUserKeys) title={`当前已选:${selectedUserSummary}`}
.map((id) => userNameMap.get(Number(id)) || id) >
.join("、") || "无"} 当前已选:{selectedUserSummary}
</p> </p>
<p className="text-xs text-default-500"> <p className="text-xs text-default-500">
不选择任何用户并保存将清空该分组成员。 不选择任何用户并保存将清空该分组成员。
+3 -3
View File
@@ -1,11 +1,11 @@
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Turnstile } from "@marsidev/react-turnstile"; import { Turnstile } from "@marsidev/react-turnstile";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Button } from "@/shadcn-bridge/heroui/button";
import { siteConfig } from "@/config/site"; import { siteConfig } from "@/config/site";
import { title } from "@/components/primitives"; import { title } from "@/components/primitives";
import DefaultLayout from "@/layouts/default"; import DefaultLayout from "@/layouts/default";
+2 -2
View File
@@ -1,4 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import toast from "react-hot-toast";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
@@ -11,8 +13,6 @@ import {
ModalFooter, ModalFooter,
} from "@/shadcn-bridge/heroui/modal"; } from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@/shadcn-bridge/heroui/chip"; import { Chip } from "@/shadcn-bridge/heroui/chip";
import toast from "react-hot-toast";
import { import {
createSpeedLimit, createSpeedLimit,
getSpeedLimitList, getSpeedLimitList,
+31 -21
View File
@@ -1,23 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from "react"; import { useState, useEffect, useMemo, useCallback } from "react";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Textarea } from "@/shadcn-bridge/heroui/input";
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
} from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Switch } from "@/shadcn-bridge/heroui/switch";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Progress } from "@/shadcn-bridge/heroui/progress";
import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { import {
DndContext, DndContext,
@@ -37,6 +18,25 @@ import {
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Textarea } from "@/shadcn-bridge/heroui/input";
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
} from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Switch } from "@/shadcn-bridge/heroui/switch";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Progress } from "@/shadcn-bridge/heroui/progress";
import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { import {
createNode, createNode,
getNodeList, getNodeList,
@@ -1020,10 +1020,20 @@ export default function NodePage() {
<span className="text-sm font-medium shrink-0"> <span className="text-sm font-medium shrink-0">
已选 {selectedIds.size} 项 已选 {selectedIds.size} 项
</span> </span>
<Button color="primary" size="sm" variant="flat" onPress={selectAll}> <Button
color="primary"
size="sm"
variant="flat"
onPress={selectAll}
>
全选 全选
</Button> </Button>
<Button color="secondary" size="sm" variant="flat" onPress={deselectAll}> <Button
color="secondary"
size="sm"
variant="flat"
onPress={deselectAll}
>
清空 清空
</Button> </Button>
<Button <Button
+2 -2
View File
@@ -1,4 +1,6 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Tabs, Tab } from "@/shadcn-bridge/heroui/tabs"; import { Tabs, Tab } from "@/shadcn-bridge/heroui/tabs";
@@ -11,8 +13,6 @@ import {
ModalFooter, ModalFooter,
} from "@/shadcn-bridge/heroui/modal"; } from "@/shadcn-bridge/heroui/modal";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select"; import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { toast } from "react-hot-toast";
import { import {
getNodeList, getNodeList,
createPeerShare, createPeerShare,
+3 -3
View File
@@ -1,4 +1,7 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { toast } from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card"; import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { import {
@@ -10,9 +13,6 @@ import {
useDisclosure, useDisclosure,
} from "@/shadcn-bridge/heroui/modal"; } from "@/shadcn-bridge/heroui/modal";
import { Input } from "@/shadcn-bridge/heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
import { toast } from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { isWebViewFunc } from "@/utils/panel"; import { isWebViewFunc } from "@/utils/panel";
import { siteConfig } from "@/config/site"; import { siteConfig } from "@/config/site";
import { updatePassword } from "@/api"; import { updatePassword } from "@/api";
+3 -3
View File
@@ -1,10 +1,10 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { reinitializeBaseURL } from "@/api/network"; import { reinitializeBaseURL } from "@/api/network";
import { import {
getPanelAddresses, getPanelAddresses,
+28 -18
View File
@@ -1,20 +1,4 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
} from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Divider } from "@/shadcn-bridge/heroui/divider";
import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { import {
DndContext, DndContext,
@@ -34,6 +18,22 @@ import {
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
} from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Divider } from "@/shadcn-bridge/heroui/divider";
import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { import {
createTunnel, createTunnel,
getTunnelList, getTunnelList,
@@ -697,10 +697,20 @@ export default function TunnelPage() {
<span className="text-sm text-default-600 shrink-0"> <span className="text-sm text-default-600 shrink-0">
已选择 {selectedIds.size} 项 已选择 {selectedIds.size} 项
</span> </span>
<Button color="primary" size="sm" variant="flat" onPress={selectAll}> <Button
color="primary"
size="sm"
variant="flat"
onPress={selectAll}
>
全选 全选
</Button> </Button>
<Button color="secondary" size="sm" variant="flat" onPress={deselectAll}> <Button
color="secondary"
size="sm"
variant="flat"
onPress={deselectAll}
>
清空 清空
</Button> </Button>
<Button <Button
+3 -3
View File
@@ -1,4 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import toast from "react-hot-toast";
import { parseDate } from "@internationalized/date";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Input } from "@/shadcn-bridge/heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
@@ -25,9 +28,6 @@ import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { DatePicker } from "@/shadcn-bridge/heroui/date-picker"; import { DatePicker } from "@/shadcn-bridge/heroui/date-picker";
import { Spinner } from "@/shadcn-bridge/heroui/spinner"; import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Progress } from "@/shadcn-bridge/heroui/progress"; import { Progress } from "@/shadcn-bridge/heroui/progress";
import toast from "react-hot-toast";
import { parseDate } from "@internationalized/date";
import { import {
User, User,
UserForm, UserForm,
+1 -1
View File
@@ -1,9 +1,9 @@
import * as React from "react"; import * as React from "react";
import { HeroUIProvider } from "@/shadcn-bridge/heroui/system";
import { useHref, useNavigate } from "react-router-dom"; import { useHref, useNavigate } from "react-router-dom";
import { Toaster } from "react-hot-toast"; import { Toaster } from "react-hot-toast";
import { I18nProvider } from "@react-aria/i18n"; import { I18nProvider } from "@react-aria/i18n";
import { HeroUIProvider } from "@/shadcn-bridge/heroui/system";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
export interface ProvidersProps { export interface ProvidersProps {
@@ -9,7 +9,8 @@ import {
type AlertColor = "default" | "success" | "warning" | "danger" | "primary"; type AlertColor = "default" | "success" | "warning" | "danger" | "primary";
type AlertVariant = "solid" | "flat" | "faded" | "bordered"; type AlertVariant = "solid" | "flat" | "faded" | "bordered";
interface AlertProps extends Omit<React.ComponentProps<"div">, "color" | "title"> { interface AlertProps
extends Omit<React.ComponentProps<"div">, "color" | "title"> {
color?: AlertColor; color?: AlertColor;
description?: React.ReactNode; description?: React.ReactNode;
title?: React.ReactNode; title?: React.ReactNode;
@@ -4,8 +4,20 @@ import { Loader2Icon } from "lucide-react";
import { Button as BaseButton } from "@/components/ui/button"; import { Button as BaseButton } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type HeroButtonColor = "default" | "primary" | "secondary" | "success" | "warning" | "danger"; type HeroButtonColor =
type HeroButtonVariant = "solid" | "light" | "flat" | "ghost" | "bordered" | "shadow"; | "default"
| "primary"
| "secondary"
| "success"
| "warning"
| "danger";
type HeroButtonVariant =
| "solid"
| "light"
| "flat"
| "ghost"
| "bordered"
| "shadow";
type HeroButtonSize = "sm" | "md" | "lg"; type HeroButtonSize = "sm" | "md" | "lg";
export interface ButtonProps export interface ButtonProps
@@ -20,7 +32,17 @@ export interface ButtonProps
variant?: HeroButtonVariant; variant?: HeroButtonVariant;
} }
function mapVariant(color: HeroButtonColor, variant: HeroButtonVariant): "default" | "destructive" | "secondary" | "outline" | "ghost" | "light" | "flat" { function mapVariant(
color: HeroButtonColor,
variant: HeroButtonVariant,
):
| "default"
| "destructive"
| "secondary"
| "outline"
| "ghost"
| "light"
| "flat" {
if (variant === "bordered") { if (variant === "bordered") {
return "outline"; return "outline";
} }
@@ -43,7 +65,10 @@ function mapVariant(color: HeroButtonColor, variant: HeroButtonVariant): "defaul
return "default"; return "default";
} }
function mapSize(size: HeroButtonSize, isIconOnly: boolean): "default" | "sm" | "lg" | "icon" { function mapSize(
size: HeroButtonSize,
isIconOnly: boolean,
): "default" | "sm" | "lg" | "icon" {
if (isIconOnly) { if (isIconOnly) {
return "icon"; return "icon";
} }
@@ -67,33 +92,46 @@ const solidColorClassMap: Partial<Record<HeroButtonColor, string>> = {
const borderedColorClassMap: Record<HeroButtonColor, string> = { const borderedColorClassMap: Record<HeroButtonColor, string> = {
default: default:
"border-default-300 text-default-700 hover:bg-default-100 dark:border-default-300 dark:text-default-600 dark:hover:bg-default-200/40", "border-default-300 text-default-700 hover:bg-default-100 dark:border-default-300 dark:text-default-600 dark:hover:bg-default-200/40",
primary: "border-primary text-primary hover:bg-primary-50 dark:border-primary-500/60 dark:text-primary-300 dark:hover:bg-primary-900/20", primary:
"border-primary text-primary hover:bg-primary-50 dark:border-primary-500/60 dark:text-primary-300 dark:hover:bg-primary-900/20",
secondary: secondary:
"border-secondary text-secondary hover:bg-secondary-50 dark:border-secondary-500/60 dark:text-secondary-300 dark:hover:bg-secondary-900/20", "border-secondary text-secondary hover:bg-secondary-50 dark:border-secondary-500/60 dark:text-secondary-300 dark:hover:bg-secondary-900/20",
success: "border-success text-success hover:bg-success-50 dark:border-success-500/60 dark:text-success-300 dark:hover:bg-success-900/20", success:
"border-success text-success hover:bg-success-50 dark:border-success-500/60 dark:text-success-300 dark:hover:bg-success-900/20",
warning: warning:
"border-warning text-warning-700 hover:bg-warning-50 dark:border-warning-500/60 dark:text-warning-300 dark:hover:bg-warning-900/20", "border-warning text-warning-700 hover:bg-warning-50 dark:border-warning-500/60 dark:text-warning-300 dark:hover:bg-warning-900/20",
danger: "border-danger text-danger hover:bg-danger-50 dark:border-danger-500/60 dark:text-danger-300 dark:hover:bg-danger-900/20", danger:
"border-danger text-danger hover:bg-danger-50 dark:border-danger-500/60 dark:text-danger-300 dark:hover:bg-danger-900/20",
}; };
const lightColorClassMap: Record<HeroButtonColor, string> = { const lightColorClassMap: Record<HeroButtonColor, string> = {
default: "text-default-700 hover:bg-default-100 dark:text-default-600 dark:hover:bg-default-200/40", default:
primary: "text-primary hover:bg-primary-100/70 dark:text-primary-300 dark:hover:bg-primary-900/30", "text-default-700 hover:bg-default-100 dark:text-default-600 dark:hover:bg-default-200/40",
secondary: "text-secondary hover:bg-secondary-100/70 dark:text-secondary-300 dark:hover:bg-secondary-900/30", primary:
success: "text-success hover:bg-success-100/70 dark:text-success-300 dark:hover:bg-success-900/30", "text-primary hover:bg-primary-100/70 dark:text-primary-300 dark:hover:bg-primary-900/30",
warning: "text-warning-700 hover:bg-warning-100/70 dark:text-warning-300 dark:hover:bg-warning-900/30", secondary:
danger: "text-danger hover:bg-danger-100/70 dark:text-danger-300 dark:hover:bg-danger-900/30", "text-secondary hover:bg-secondary-100/70 dark:text-secondary-300 dark:hover:bg-secondary-900/30",
success:
"text-success hover:bg-success-100/70 dark:text-success-300 dark:hover:bg-success-900/30",
warning:
"text-warning-700 hover:bg-warning-100/70 dark:text-warning-300 dark:hover:bg-warning-900/30",
danger:
"text-danger hover:bg-danger-100/70 dark:text-danger-300 dark:hover:bg-danger-900/30",
}; };
const flatColorClassMap: Record<HeroButtonColor, string> = { const flatColorClassMap: Record<HeroButtonColor, string> = {
default: default:
"bg-default-100 text-default-700 hover:bg-default-200 dark:bg-default-100/45 dark:text-default-700 dark:hover:bg-default-200/60", "bg-default-100 text-default-700 hover:bg-default-200 dark:bg-default-100/45 dark:text-default-700 dark:hover:bg-default-200/60",
primary: "bg-primary-100 text-primary-700 hover:bg-primary-200 dark:bg-primary-900/30 dark:text-primary-300 dark:hover:bg-primary-900/45", primary:
"bg-primary-100 text-primary-700 hover:bg-primary-200 dark:bg-primary-900/30 dark:text-primary-300 dark:hover:bg-primary-900/45",
secondary: secondary:
"bg-secondary-100 text-secondary-700 hover:bg-secondary-200 dark:bg-secondary-900/30 dark:text-secondary-300 dark:hover:bg-secondary-900/45", "bg-secondary-100 text-secondary-700 hover:bg-secondary-200 dark:bg-secondary-900/30 dark:text-secondary-300 dark:hover:bg-secondary-900/45",
success: "bg-success-100 text-success-700 hover:bg-success-200 dark:bg-success-900/30 dark:text-success-300 dark:hover:bg-success-900/45", success:
warning: "bg-warning-100 text-warning-700 hover:bg-warning-200 dark:bg-warning-900/30 dark:text-warning-300 dark:hover:bg-warning-900/45", "bg-success-100 text-success-700 hover:bg-success-200 dark:bg-success-900/30 dark:text-success-300 dark:hover:bg-success-900/45",
danger: "bg-danger-100 text-danger-700 hover:bg-danger-200 dark:bg-danger-900/30 dark:text-danger-300 dark:hover:bg-danger-900/45", warning:
"bg-warning-100 text-warning-700 hover:bg-warning-200 dark:bg-warning-900/30 dark:text-warning-300 dark:hover:bg-warning-900/45",
danger:
"bg-danger-100 text-danger-700 hover:bg-danger-200 dark:bg-danger-900/30 dark:text-danger-300 dark:hover:bg-danger-900/45",
}; };
const shadowColorClassMap: Record<HeroButtonColor, string> = { const shadowColorClassMap: Record<HeroButtonColor, string> = {
@@ -105,7 +143,10 @@ const shadowColorClassMap: Record<HeroButtonColor, string> = {
danger: "shadow-md shadow-danger-500/35", danger: "shadow-md shadow-danger-500/35",
}; };
function mapColorClass(color: HeroButtonColor, variant: HeroButtonVariant): string { function mapColorClass(
color: HeroButtonColor,
variant: HeroButtonVariant,
): string {
if (variant === "bordered") { if (variant === "bordered") {
return borderedColorClassMap[color]; return borderedColorClassMap[color];
} }
@@ -122,7 +163,10 @@ function mapColorClass(color: HeroButtonColor, variant: HeroButtonVariant): stri
return ""; return "";
} }
function mapShadowClass(color: HeroButtonColor, variant: HeroButtonVariant): string { function mapShadowClass(
color: HeroButtonColor,
variant: HeroButtonVariant,
): string {
if (variant !== "shadow") { if (variant !== "shadow") {
return ""; return "";
} }
@@ -169,16 +213,23 @@ export const Button = React.forwardRef<
return ( return (
<BaseButton <BaseButton
className={cn(isIconOnly ? "p-0" : "", resolvedColorClass, resolvedShadowClass, className)}
disabled={resolvedDisabled}
ref={ref} ref={ref}
className={cn(
isIconOnly ? "p-0" : "",
resolvedColorClass,
resolvedShadowClass,
className,
)}
disabled={resolvedDisabled}
size={resolvedSize} size={resolvedSize}
type={type} type={type}
variant={resolvedVariant} variant={resolvedVariant}
onClick={handleClick} onClick={handleClick}
{...props} {...props}
> >
{isLoading ? <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> : null} {isLoading ? (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
) : null}
{startContent} {startContent}
{isIconOnly ? null : children} {isIconOnly ? null : children}
{isIconOnly ? children : null} {isIconOnly ? children : null}
@@ -11,10 +11,18 @@ export function Card({ className, ...props }: React.ComponentProps<"div">) {
return <BaseCard className={className} {...props} />; return <BaseCard className={className} {...props} />;
} }
export function CardHeader({ className, ...props }: React.ComponentProps<"div">) { export function CardHeader({
className,
...props
}: React.ComponentProps<"div">) {
return <BaseCardHeader className={cn("p-4 md:p-6", className)} {...props} />; return <BaseCardHeader className={cn("p-4 md:p-6", className)} {...props} />;
} }
export function CardBody({ className, ...props }: React.ComponentProps<"div">) { export function CardBody({ className, ...props }: React.ComponentProps<"div">) {
return <CardContent className={cn("p-4 pt-0 md:p-6 md:pt-0", className)} {...props} />; return (
<CardContent
className={cn("p-4 pt-0 md:p-6 md:pt-0", className)}
{...props}
/>
);
} }
@@ -4,7 +4,10 @@ import { Checkbox as BaseCheckbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface CheckboxProps export interface CheckboxProps
extends Omit<React.ComponentProps<typeof BaseCheckbox>, "checked" | "onCheckedChange"> { extends Omit<
React.ComponentProps<typeof BaseCheckbox>,
"checked" | "onCheckedChange"
> {
classNames?: Record<string, string>; classNames?: Record<string, string>;
color?: string; color?: string;
isDisabled?: boolean; isDisabled?: boolean;
@@ -30,7 +33,13 @@ export function Checkbox({
}; };
return ( return (
<div className={cn("inline-flex items-center gap-2", isDisabled ? "opacity-50" : "", className)}> <div
className={cn(
"inline-flex items-center gap-2",
isDisabled ? "opacity-50" : "",
className,
)}
>
<BaseCheckbox <BaseCheckbox
checked={Boolean(isSelected)} checked={Boolean(isSelected)}
disabled={isDisabled} disabled={isDisabled}
@@ -3,7 +3,13 @@ import * as React from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type ChipColor = "default" | "primary" | "secondary" | "success" | "warning" | "danger"; type ChipColor =
| "default"
| "primary"
| "secondary"
| "success"
| "warning"
| "danger";
type ChipVariant = "solid" | "flat" | "light" | "bordered"; type ChipVariant = "solid" | "flat" | "light" | "bordered";
type ChipSize = "sm" | "md" | "lg"; type ChipSize = "sm" | "md" | "lg";
@@ -1,10 +1,10 @@
import * as React from "react"; import * as React from "react";
import { FieldContainer, type FieldMetaProps } from "./shared";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FieldContainer, type FieldMetaProps } from "./shared";
interface CalendarDateLike { interface CalendarDateLike {
day: number; day: number;
month: number; month: number;
@@ -76,7 +76,11 @@ export function DatePicker({
const month = Number(monthText); const month = Number(monthText);
const day = Number(dayText); const day = Number(dayText);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { if (
!Number.isFinite(year) ||
!Number.isFinite(month) ||
!Number.isFinite(day)
) {
onChange(null); onChange(null);
return; return;
@@ -8,7 +8,6 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger as BaseDropdownMenuTrigger, DropdownMenuTrigger as BaseDropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type Placement = "bottom-start" | "bottom-end" | "top-start" | "top-end"; type Placement = "bottom-start" | "bottom-end" | "top-start" | "top-end";
@@ -17,14 +16,19 @@ interface DropdownContextValue {
align: "start" | "end"; align: "start" | "end";
} }
const DropdownContext = React.createContext<DropdownContextValue>({ align: "start" }); const DropdownContext = React.createContext<DropdownContextValue>({
align: "start",
});
export interface DropdownProps { export interface DropdownProps {
children: React.ReactNode; children: React.ReactNode;
placement?: Placement; placement?: Placement;
} }
export function Dropdown({ children, placement = "bottom-start" }: DropdownProps) { export function Dropdown({
children,
placement = "bottom-start",
}: DropdownProps) {
const align = placement.endsWith("end") ? "end" : "start"; const align = placement.endsWith("end") ? "end" : "start";
return ( return (
@@ -66,7 +70,10 @@ export function DropdownItem({
}: DropdownItemProps) { }: DropdownItemProps) {
return ( return (
<BaseDropdownMenuItem <BaseDropdownMenuItem
className={cn(color === "danger" ? "text-danger focus:text-danger" : "", className)} className={cn(
color === "danger" ? "text-danger focus:text-danger" : "",
className,
)}
onSelect={(event) => { onSelect={(event) => {
event.preventDefault(); event.preventDefault();
onPress?.(); onPress?.();
@@ -1,11 +1,11 @@
import * as React from "react"; import * as React from "react";
import { FieldContainer, type FieldMetaProps } from "./shared";
import { Input as BaseInput } from "@/components/ui/input"; import { Input as BaseInput } from "@/components/ui/input";
import { Textarea as BaseTextarea } from "@/components/ui/textarea"; import { Textarea as BaseTextarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FieldContainer, type FieldMetaProps } from "./shared";
type ClassNameMap = { type ClassNameMap = {
base?: string; base?: string;
description?: string; description?: string;
@@ -84,7 +84,9 @@ export function Input({
classNames?.inputWrapper, classNames?.inputWrapper,
)} )}
> >
{startContent ? <div className="pl-3 text-default-500">{startContent}</div> : null} {startContent ? (
<div className="pl-3 text-default-500">{startContent}</div>
) : null}
<BaseInput <BaseInput
aria-invalid={isInvalid} aria-invalid={isInvalid}
className={cn( className={cn(
@@ -100,7 +102,9 @@ export function Input({
required={isRequired} required={isRequired}
{...props} {...props}
/> />
{endContent ? <div className="pr-3 text-default-500">{endContent}</div> : null} {endContent ? (
<div className="pr-3 text-default-500">{endContent}</div>
) : null}
</div> </div>
</FieldContainer> </FieldContainer>
); );
@@ -20,5 +20,10 @@ function mapColor(color: LinkColor) {
} }
export function Link({ className, color = "default", ...props }: LinkProps) { export function Link({ className, color = "default", ...props }: LinkProps) {
return <a className={cn("transition-colors", mapColor(color), className)} {...props} />; return (
<a
className={cn("transition-colors", mapColor(color), className)}
{...props}
/>
);
} }
@@ -52,7 +52,12 @@ export function Navbar({
style={height ? { minHeight: height } : undefined} style={height ? { minHeight: height } : undefined}
{...props} {...props}
> >
<div className={cn("mx-auto flex h-full w-full items-center justify-between px-4", maxWidthClass(maxWidth))}> <div
className={cn(
"mx-auto flex h-full w-full items-center justify-between px-4",
maxWidthClass(maxWidth),
)}
>
{children} {children}
</div> </div>
</nav> </nav>
@@ -85,6 +90,9 @@ export function NavbarContent({
); );
} }
export function NavbarBrand({ className, ...props }: React.ComponentProps<"div">) { export function NavbarBrand({
className,
...props
}: React.ComponentProps<"div">) {
return <div className={cn("flex items-center", className)} {...props} />; return <div className={cn("flex items-center", className)} {...props} />;
} }
@@ -3,7 +3,13 @@ import * as React from "react";
import { Progress as BaseProgress } from "@/components/ui/progress"; import { Progress as BaseProgress } from "@/components/ui/progress";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type ProgressColor = "default" | "primary" | "secondary" | "success" | "warning" | "danger"; type ProgressColor =
| "default"
| "primary"
| "secondary"
| "success"
| "warning"
| "danger";
export interface ProgressProps { export interface ProgressProps {
"aria-label"?: string; "aria-label"?: string;
@@ -1,6 +1,9 @@
import * as React from "react"; import * as React from "react";
import { RadioGroup as BaseRadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import {
RadioGroup as BaseRadioGroup,
RadioGroupItem,
} from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -8,7 +11,9 @@ interface RadioContextValue {
name: string; name: string;
} }
const RadioContext = React.createContext<RadioContextValue>({ name: "radio-group" }); const RadioContext = React.createContext<RadioContextValue>({
name: "radio-group",
});
export interface RadioGroupProps { export interface RadioGroupProps {
children: React.ReactNode; children: React.ReactNode;
@@ -33,7 +38,9 @@ export function RadioGroup({
<RadioContext.Provider value={{ name: generatedName }}> <RadioContext.Provider value={{ name: generatedName }}>
<BaseRadioGroup <BaseRadioGroup
className={cn( className={cn(
orientation === "horizontal" ? "flex flex-wrap items-center gap-4" : "grid gap-3", orientation === "horizontal"
? "flex flex-wrap items-center gap-4"
: "grid gap-3",
)} )}
value={value} value={value}
onValueChange={onValueChange} onValueChange={onValueChange}
+133 -68
View File
@@ -169,19 +169,71 @@ export function Select<T>({
() => getOptions(children, items), () => getOptions(children, items),
[children, items], [children, items],
); );
const containerRef = React.useRef<HTMLDivElement | null>(null);
const listboxRef = React.useRef<HTMLDivElement | null>(null);
const [isExpanded, setIsExpanded] = React.useState(false); const [isExpanded, setIsExpanded] = React.useState(false);
const selected = React.useMemo(() => toSet(selectedKeys), [selectedKeys]); const selected = React.useMemo(() => toSet(selectedKeys), [selectedKeys]);
const disabled = React.useMemo(() => toSet(disabledKeys), [disabledKeys]); const disabled = React.useMemo(() => toSet(disabledKeys), [disabledKeys]);
React.useEffect(() => {
if (!isExpanded) {
return;
}
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
const container = containerRef.current;
const listbox = listboxRef.current;
if (!container) {
return;
}
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (container.contains(target) || listbox?.contains(target)) {
return;
}
setIsExpanded(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsExpanded(false);
}
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("touchstart", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("touchstart", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isExpanded]);
React.useEffect(() => {
if (isDisabled) {
setIsExpanded(false);
}
}, [isDisabled]);
const selectedArray = Array.from(selected); const selectedArray = Array.from(selected);
const singleValue = selectedArray[0] ?? ""; const singleValue = selectedArray[0] ?? "";
const selectedLabels = options
.filter((option) => selected.has(option.key))
.map((option) => option.label);
const resolvedSelectedValues =
selectedLabels.length > 0 ? selectedLabels : selectedArray;
const selectedFullText = resolvedSelectedValues.join("、");
const selectedText = const selectedText =
selectedArray.length > 0 selectedArray.length > 0 ? selectedFullText : (placeholder ?? "请选择");
? options
.filter((option) => selected.has(option.key))
.map((option) => option.label)
.join("、")
: (placeholder ?? "请选择");
const updateMultipleSelection = (key: string, checked?: boolean) => { const updateMultipleSelection = (key: string, checked?: boolean) => {
if (isDisabled || disabled.has(key)) { if (isDisabled || disabled.has(key)) {
@@ -227,6 +279,70 @@ export function Select<T>({
onSelectionChange(new Set([event.target.value])); onSelectionChange(new Set([event.target.value]));
}; };
const renderMultipleListbox = () => {
if (!isExpanded) {
return null;
}
return (
<div
className="absolute left-0 top-full z-50 mt-1 max-h-56 w-full space-y-1 overflow-y-auto rounded-md border border-divider bg-background p-2 shadow-md"
id={`${generatedId}-listbox`}
ref={listboxRef}
role="listbox"
>
{options.length === 0 ? (
<div
className={cn(
"px-2 py-1 text-default-500",
textSizeClass(size),
)}
>
暂无可选项
</div>
) : (
options.map((option) => {
const optionDisabled = isDisabled || disabled.has(option.key);
return (
<div
key={option.key}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5",
optionDisabled
? "cursor-not-allowed opacity-60"
: "hover:bg-default-100",
)}
>
<BaseCheckbox
checked={selected.has(option.key)}
disabled={optionDisabled}
onCheckedChange={(value) =>
updateMultipleSelection(option.key, value === true)
}
/>
<button
className={cn(
"min-w-0 flex-1 truncate text-left text-foreground",
textSizeClass(size),
optionDisabled
? "cursor-not-allowed"
: "cursor-pointer",
)}
disabled={optionDisabled}
type="button"
onClick={() => updateMultipleSelection(option.key)}
>
{option.label}
</button>
</div>
);
})
)}
</div>
);
};
return ( return (
<FieldContainer <FieldContainer
className={classNames?.base} className={classNames?.base}
@@ -239,21 +355,23 @@ export function Select<T>({
> >
{selectionMode === "multiple" ? ( {selectionMode === "multiple" ? (
<div <div
ref={containerRef}
className={cn( className={cn(
"w-full overflow-hidden rounded-md border border-input bg-background shadow-sm", "relative w-full",
isDisabled ? "cursor-not-allowed opacity-60" : "",
className, className,
)} )}
id={generatedId}
> >
<button <button
aria-controls={`${generatedId}-listbox`} aria-controls={`${generatedId}-listbox`}
aria-expanded={isExpanded} aria-expanded={isExpanded}
aria-haspopup="listbox"
className={cn( className={cn(
"flex w-full min-w-0 items-center gap-2 overflow-hidden px-3 py-2 text-left", "flex w-full min-w-0 items-center gap-2 overflow-hidden rounded-md border border-input bg-background px-3 py-2 text-left shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isDisabled ? "cursor-not-allowed opacity-60" : "",
classNames?.trigger, classNames?.trigger,
)} )}
disabled={isDisabled} disabled={isDisabled}
id={generatedId}
type="button" type="button"
onClick={() => setIsExpanded((prev) => !prev)} onClick={() => setIsExpanded((prev) => !prev)}
> >
@@ -261,9 +379,11 @@ export function Select<T>({
className={cn( className={cn(
"block min-w-0 flex-1 truncate", "block min-w-0 flex-1 truncate",
textSizeClass(size), textSizeClass(size),
selectedArray.length > 0 ? "text-foreground" : "text-default-500", selectedArray.length > 0
? "text-foreground"
: "text-default-500",
)} )}
title={selectedText} title={selectedArray.length > 0 ? selectedFullText : undefined}
> >
{selectedText} {selectedText}
</span> </span>
@@ -274,62 +394,7 @@ export function Select<T>({
)} )}
/> />
</button> </button>
{isExpanded ? ( {renderMultipleListbox()}
<div
className="max-h-56 space-y-1 overflow-y-auto border-t border-divider p-2"
id={`${generatedId}-listbox`}
role="listbox"
>
{options.length === 0 ? (
<div
className={cn(
"px-2 py-1 text-default-500",
textSizeClass(size),
)}
>
暂无可选项
</div>
) : (
options.map((option) => {
const optionDisabled = isDisabled || disabled.has(option.key);
return (
<div
key={option.key}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5",
optionDisabled
? "cursor-not-allowed opacity-60"
: "hover:bg-default-100",
)}
>
<BaseCheckbox
checked={selected.has(option.key)}
disabled={optionDisabled}
onCheckedChange={(value) =>
updateMultipleSelection(option.key, value === true)
}
/>
<button
className={cn(
"min-w-0 flex-1 truncate text-left text-foreground",
textSizeClass(size),
optionDisabled
? "cursor-not-allowed"
: "cursor-pointer",
)}
disabled={optionDisabled}
type="button"
onClick={() => updateMultipleSelection(option.key)}
>
{option.label}
</button>
</div>
);
})
)}
</div>
) : null}
</div> </div>
) : ( ) : (
<select <select
@@ -36,7 +36,9 @@ export function FieldContainer({
</Label> </Label>
) : null} ) : null}
{children} {children}
{description ? <p className="text-xs text-default-500">{description}</p> : null} {description ? (
<p className="text-xs text-default-500">{description}</p>
) : null}
{isInvalid && errorMessage ? ( {isInvalid && errorMessage ? (
<p className="text-xs text-danger">{errorMessage}</p> <p className="text-xs text-danger">{errorMessage}</p>
) : null} ) : null}
@@ -45,14 +47,21 @@ export function FieldContainer({
} }
export function extractText(content: React.ReactNode): string { export function extractText(content: React.ReactNode): string {
if (content === null || content === undefined || typeof content === "boolean") { if (
content === null ||
content === undefined ||
typeof content === "boolean"
) {
return ""; return "";
} }
if (typeof content === "string" || typeof content === "number") { if (typeof content === "string" || typeof content === "number") {
return String(content); return String(content);
} }
if (Array.isArray(content)) { if (Array.isArray(content)) {
return content.map((item) => extractText(item)).join("").trim(); return content
.map((item) => extractText(item))
.join("")
.trim();
} }
if (React.isValidElement(content)) { if (React.isValidElement(content)) {
return extractText(content.props.children); return extractText(content.props.children);
@@ -21,10 +21,17 @@ function iconSize(size: SpinnerSize | undefined) {
return "h-5 w-5"; return "h-5 w-5";
} }
export function Spinner({ className, label, size = "md", ...props }: SpinnerProps) { export function Spinner({
className,
label,
size = "md",
...props
}: SpinnerProps) {
return ( return (
<div className={cn("inline-flex items-center gap-2", className)} {...props}> <div className={cn("inline-flex items-center gap-2", className)} {...props}>
<Loader2Icon className={cn("animate-spin text-default-500", iconSize(size))} /> <Loader2Icon
className={cn("animate-spin text-default-500", iconSize(size))}
/>
{label ? <span className="text-sm text-default-500">{label}</span> : null} {label ? <span className="text-sm text-default-500">{label}</span> : null}
</div> </div>
); );
@@ -3,7 +3,11 @@ import * as React from "react";
import { Switch as BaseSwitch } from "@/components/ui/switch"; import { Switch as BaseSwitch } from "@/components/ui/switch";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface SwitchProps extends Omit<React.ComponentProps<typeof BaseSwitch>, "checked" | "onCheckedChange"> { export interface SwitchProps
extends Omit<
React.ComponentProps<typeof BaseSwitch>,
"checked" | "onCheckedChange"
> {
classNames?: Record<string, string>; classNames?: Record<string, string>;
color?: string; color?: string;
isDisabled?: boolean; isDisabled?: boolean;
@@ -17,10 +17,17 @@ export interface TableProps extends React.ComponentProps<"table"> {
classNames?: TableClassNames; classNames?: TableClassNames;
} }
export function Table({ children, className, classNames, ...props }: TableProps) { export function Table({
children,
className,
classNames,
...props
}: TableProps) {
return ( return (
<TableStyleContext.Provider value={{ thClassName: classNames?.th }}> <TableStyleContext.Provider value={{ thClassName: classNames?.th }}>
<div className={cn("w-full overflow-auto rounded-md", classNames?.wrapper)}> <div
className={cn("w-full overflow-auto rounded-md", classNames?.wrapper)}
>
<table className={cn("w-full text-sm", className)} {...props}> <table className={cn("w-full text-sm", className)} {...props}>
{children} {children}
</table> </table>
@@ -29,7 +36,11 @@ export function Table({ children, className, classNames, ...props }: TableProps)
); );
} }
export function TableHeader({ children, className, ...props }: React.ComponentProps<"thead">) { export function TableHeader({
children,
className,
...props
}: React.ComponentProps<"thead">) {
const childArray = React.Children.toArray(children); const childArray = React.Children.toArray(children);
const hasRow = childArray.some( const hasRow = childArray.some(
(child) => React.isValidElement(child) && child.type === TableRow, (child) => React.isValidElement(child) && child.type === TableRow,
@@ -42,7 +53,8 @@ export function TableHeader({ children, className, ...props }: React.ComponentPr
); );
} }
interface TableBodyProps<T> extends Omit<React.ComponentProps<"tbody">, "children"> { interface TableBodyProps<T>
extends Omit<React.ComponentProps<"tbody">, "children"> {
children?: React.ReactNode | ((item: T) => React.ReactNode); children?: React.ReactNode | ((item: T) => React.ReactNode);
emptyContent?: React.ReactNode; emptyContent?: React.ReactNode;
isLoading?: boolean; isLoading?: boolean;
@@ -120,19 +132,28 @@ export function TableBody<T>({
); );
} }
export function TableColumn({ className, ...props }: React.ComponentProps<"th">) { export function TableColumn({
className,
...props
}: React.ComponentProps<"th">) {
const { thClassName } = React.useContext(TableStyleContext); const { thClassName } = React.useContext(TableStyleContext);
return ( return (
<th <th
className={cn("px-3 py-2 text-left font-medium text-default-600", thClassName, className)} className={cn(
"px-3 py-2 text-left font-medium text-default-600",
thClassName,
className,
)}
{...props} {...props}
/> />
); );
} }
export function TableRow({ className, ...props }: React.ComponentProps<"tr">) { export function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return <tr className={cn("border-b last:border-b-0", className)} {...props} />; return (
<tr className={cn("border-b last:border-b-0", className)} {...props} />
);
} }
export function TableCell({ className, ...props }: React.ComponentProps<"td">) { export function TableCell({ className, ...props }: React.ComponentProps<"td">) {
@@ -10,11 +10,14 @@ function resolveInitialTheme(): ThemeMode {
} }
const fromStorage = window.localStorage.getItem(STORAGE_KEY); const fromStorage = window.localStorage.getItem(STORAGE_KEY);
if (fromStorage === "dark" || fromStorage === "light") { if (fromStorage === "dark" || fromStorage === "light") {
return fromStorage; return fromStorage;
} }
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
} }
let currentTheme: ThemeMode = resolveInitialTheme(); let currentTheme: ThemeMode = resolveInitialTheme();