mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-21 21:47:00 +08:00
feat: 新增组件库
This commit is contained in:
@@ -68,9 +68,24 @@
|
|||||||
"@electron-toolkit/preload": "^3.0.2",
|
"@electron-toolkit/preload": "^3.0.2",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"@koromix/koffi-win32-x64": "3.1.0",
|
"@koromix/koffi-win32-x64": "3.1.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.11",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.23",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||||
"@radix-ui/react-popover": "^1.1.23",
|
"@radix-ui/react-popover": "^1.1.23",
|
||||||
|
"@radix-ui/react-progress": "^1.1.16",
|
||||||
|
"@radix-ui/react-radio-group": "^1.4.7",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||||
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
|
"@radix-ui/react-separator": "^1.1.15",
|
||||||
|
"@radix-ui/react-switch": "^1.3.7",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
|
"@radix-ui/react-toast": "^1.2.23",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.16",
|
||||||
"@tanstack/react-virtual": "^3.14.6",
|
"@tanstack/react-virtual": "^3.14.6",
|
||||||
"archiver": "^8.0.0",
|
"archiver": "^8.0.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"ffmpeg-static": "5.3.0",
|
"ffmpeg-static": "5.3.0",
|
||||||
@@ -82,6 +97,7 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sherpa-onnx-node": "1.13.3",
|
"sherpa-onnx-node": "1.13.3",
|
||||||
"silk-wasm": "^3.7.1",
|
"silk-wasm": "^3.7.1",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
"wechat-emojis": "^1.0.2"
|
"wechat-emojis": "^1.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -102,6 +118,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"@vitest/coverage-v8": "^4.1.10",
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
|
"autoprefixer": "^10.5.4",
|
||||||
"electron": "^43.0.0",
|
"electron": "^43.0.0",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
"electron-vite": "^5.0.0",
|
"electron-vite": "^5.0.0",
|
||||||
@@ -110,10 +127,13 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
|
"postcss": "^8.5.26",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.1",
|
||||||
"sass": "^1.102.0",
|
"sass": "^1.102.0",
|
||||||
|
"tailwindcss": "3.4.17",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.2.6",
|
"vite": "^7.2.6",
|
||||||
"vitest": "^4.1.10",
|
"vitest": "^4.1.10",
|
||||||
|
|||||||
Generated
+1012
-7
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ import { enrichQuotedMessages } from './utils/quoted-messages'
|
|||||||
import type { SelectableReportTemplateId } from '../../shared/report-templates'
|
import type { SelectableReportTemplateId } from '../../shared/report-templates'
|
||||||
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
||||||
import { runtimePlatform } from './utils/runtime-environment'
|
import { runtimePlatform } from './utils/runtime-environment'
|
||||||
|
import { useToast } from './components/ui'
|
||||||
|
|
||||||
const SIDEBAR_MIN_WIDTH = 260
|
const SIDEBAR_MIN_WIDTH = 260
|
||||||
const SIDEBAR_MAX_WIDTH = 380
|
const SIDEBAR_MAX_WIDTH = 380
|
||||||
@@ -214,6 +215,7 @@ const buildSyntheticGroupMessages = (
|
|||||||
void buildSyntheticGroupMessages
|
void buildSyntheticGroupMessages
|
||||||
|
|
||||||
function App(): React.ReactElement {
|
function App(): React.ReactElement {
|
||||||
|
const { toast } = useToast()
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
|
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
|
||||||
const [isDatabaseConnecting, setIsDatabaseConnecting] = useState(false)
|
const [isDatabaseConnecting, setIsDatabaseConnecting] = useState(false)
|
||||||
@@ -328,9 +330,9 @@ function App(): React.ReactElement {
|
|||||||
messagesRef.current = messages
|
messagesRef.current = messages
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!reportNotice) return
|
if (!reportNotice) return
|
||||||
const timer = window.setTimeout(() => setReportNotice(''), 3200)
|
toast({ description: reportNotice, duration: 3200 })
|
||||||
return () => window.clearTimeout(timer)
|
setReportNotice('')
|
||||||
}, [reportNotice])
|
}, [reportNotice, toast])
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const openVoiceRecognitionSettings = (): void => {
|
const openVoiceRecognitionSettings = (): void => {
|
||||||
setSettingsCategory('voice-recognition')
|
setSettingsCategory('voice-recognition')
|
||||||
@@ -2111,7 +2113,6 @@ function App(): React.ReactElement {
|
|||||||
appearanceTheme={appearanceSettings.theme}
|
appearanceTheme={appearanceSettings.theme}
|
||||||
compactMode={appearanceSettings.compactMode}
|
compactMode={appearanceSettings.compactMode}
|
||||||
>
|
>
|
||||||
{reportNotice && <div className="app-toast">{reportNotice}</div>}
|
|
||||||
{showFirstUseWelcome && (
|
{showFirstUseWelcome && (
|
||||||
<FirstUseWelcome
|
<FirstUseWelcome
|
||||||
onDismiss={dismissFirstUseWelcome}
|
onDismiss={dismissFirstUseWelcome}
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ export function AppShell({
|
|||||||
compactMode = false,
|
compactMode = false,
|
||||||
children
|
children
|
||||||
}: AppShellProps): React.ReactElement {
|
}: AppShellProps): React.ReactElement {
|
||||||
|
React.useEffect(() => {
|
||||||
|
document.documentElement.dataset.theme = appearanceTheme
|
||||||
|
return () => {
|
||||||
|
delete document.documentElement.dataset.theme
|
||||||
|
}
|
||||||
|
}, [appearanceTheme])
|
||||||
|
|
||||||
const activeItem = PRIMARY_NAV_ITEMS.find((item) => item.id === activePage)
|
const activeItem = PRIMARY_NAV_ITEMS.find((item) => item.id === activePage)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||||
|
const AlertDialogCancel = AlertDialogPrimitive.Cancel
|
||||||
|
const AlertDialogAction = AlertDialogPrimitive.Action
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Portal>
|
||||||
|
<div className="fixed inset-0 z-modal flex items-start justify-center overflow-y-auto p-4 sm:items-center">
|
||||||
|
<AlertDialogPrimitive.Overlay className="fixed inset-0 bg-slate-950/45 backdrop-blur-[2px] data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in" />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-modal grid w-full max-w-md gap-4 rounded-xl border border-border bg-surface p-6 text-foreground shadow-dialog duration-normal ease-tm-emphasized data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:zoom-in-95',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AlertDialogPrimitive.Content>
|
||||||
|
</div>
|
||||||
|
</AlertDialogPrimitive.Portal>
|
||||||
|
))
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>): React.ReactElement => (
|
||||||
|
<div className={cn('flex flex-col space-y-1.5 text-left', className)} {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>): React.ReactElement => (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-base font-semibold tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogAction
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors duration-fast ease-tm-standard focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground shadow-surface hover:bg-primary/90',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/75',
|
||||||
|
ghost: 'text-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
|
outline: 'border border-border bg-surface text-foreground shadow-surface hover:bg-accent',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground shadow-surface hover:bg-destructive/90',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline'
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
lg: 'h-10 rounded-lg px-6',
|
||||||
|
icon: 'h-9 w-9'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
|
||||||
|
|
||||||
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, type = 'button', ...props }, ref) => (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
className={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Button.displayName = 'Button'
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export { buttonVariants }
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'peer h-4 w-4 shrink-0 rounded-sm border border-border bg-surface shadow-surface transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-[11px]">
|
||||||
|
✓
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
))
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogPortal = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.DialogPortalProps): React.ReactElement => (
|
||||||
|
<DialogPrimitive.Portal {...props}>
|
||||||
|
<div className="fixed inset-0 z-modal flex items-start justify-center overflow-y-auto p-4 sm:items-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
)
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-modal bg-slate-950/45 backdrop-blur-[2px] data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-modal grid w-full max-w-lg gap-4 rounded-xl border border-border bg-surface p-6 text-foreground shadow-dialog duration-normal ease-tm-emphasized data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:zoom-in-95',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none">
|
||||||
|
<span aria-hidden="true" className="text-lg leading-none">
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
<span className="sr-only">关闭</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>): React.ReactElement => (
|
||||||
|
<div className={cn('flex flex-col space-y-1.5 text-left', className)} {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>): React.ReactElement => (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-base font-semibold tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<span className="ml-auto">›</span>
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'z-dropdown min-w-32 overflow-hidden rounded-md border border-border bg-surface p-1 text-foreground shadow-popover data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & { sideOffset?: number }
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-dropdown min-w-32 overflow-hidden rounded-md border border-border bg-surface p-1 text-foreground shadow-popover data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
checked={checked}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
{checked ? '✓' : ''}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">●</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = ({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}): React.ReactElement => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1.5 text-xs font-semibold text-muted-foreground',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const DropdownMenuSeparator = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>): React.ReactElement => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-border-subtle', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>): React.ReactElement => (
|
||||||
|
<span
|
||||||
|
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
icon?: React.ReactNode
|
||||||
|
title: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
action?: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-40 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-surface-muted/50 px-6 py-8 text-center',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-muted-foreground">{icon}</div>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||||
|
{description ? (
|
||||||
|
<p className="max-w-sm text-xs text-muted-foreground">{description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Button, type ButtonProps } from './button'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip'
|
||||||
|
|
||||||
|
export interface IconButtonProps extends Omit<ButtonProps, 'size' | 'children'> {
|
||||||
|
label: string
|
||||||
|
children: React.ReactNode
|
||||||
|
tooltip?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconButton({
|
||||||
|
label,
|
||||||
|
tooltip = label,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: IconButtonProps): React.ReactElement {
|
||||||
|
const button = (
|
||||||
|
<Button aria-label={label} size="icon" {...props}>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!tooltip) return button
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent>{tooltip}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export * from './alert-dialog'
|
||||||
|
export * from './button'
|
||||||
|
export * from './checkbox'
|
||||||
|
export * from './dialog'
|
||||||
|
export * from './dropdown-menu'
|
||||||
|
export * from './empty-state'
|
||||||
|
export * from './icon-button'
|
||||||
|
export * from './input'
|
||||||
|
export * from './popover'
|
||||||
|
export * from './progress'
|
||||||
|
export * from './radio-group'
|
||||||
|
export * from './scroll-area'
|
||||||
|
export * from './select'
|
||||||
|
export * from './separator'
|
||||||
|
export * from './skeleton'
|
||||||
|
export * from './spinner'
|
||||||
|
export * from './switch'
|
||||||
|
export * from './tabs'
|
||||||
|
export * from './textarea'
|
||||||
|
export * from './toast'
|
||||||
|
export * from './tooltip'
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/* eslint-disable react/prop-types */
|
||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
export const Input = React.forwardRef<
|
||||||
|
HTMLInputElement,
|
||||||
|
React.InputHTMLAttributes<HTMLInputElement>
|
||||||
|
>(({ className, type = 'text', ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-border bg-surface px-3 py-1 text-sm text-foreground shadow-surface transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Input.displayName = 'Input'
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||||
|
const PopoverClose = PopoverPrimitive.Close
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = 'center', sideOffset = 6, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-popover w-72 rounded-lg border border-border bg-surface p-4 text-foreground shadow-popover outline-none duration-fast ease-tm-emphasized data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:zoom-in-95',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverAnchor, PopoverClose, PopoverContent }
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as ProgressPrimitive from '@radix-ui/react-progress'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||||
|
>(({ className, value, ...props }, ref) => (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
className="h-full w-full flex-1 bg-primary transition-transform duration-normal ease-tm-standard"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
))
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
const RadioGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<RadioGroupPrimitive.Root ref={ref} className={cn('grid gap-2', className)} {...props} />
|
||||||
|
))
|
||||||
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'aspect-square h-4 w-4 rounded-full border border-border bg-surface text-primary shadow-surface focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-current" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
))
|
||||||
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn('relative overflow-hidden', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
))
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'flex touch-none select-none transition-colors',
|
||||||
|
orientation === 'vertical'
|
||||||
|
? 'h-full w-2.5 border-l border-l-transparent p-px'
|
||||||
|
: 'h-2.5 flex-col border-t border-t-transparent p-px',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
))
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full items-center justify-between rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground shadow-surface focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<span aria-hidden="true" className="ml-2 text-muted-foreground">
|
||||||
|
⌄
|
||||||
|
</span>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
position={position}
|
||||||
|
className={cn(
|
||||||
|
'relative z-dropdown max-h-96 min-w-32 overflow-hidden rounded-md border border-border bg-surface text-foreground shadow-popover data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in',
|
||||||
|
position === 'popper' && 'translate-y-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('px-2 py-1.5 text-xs font-semibold text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">✓</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('my-1 h-px bg-border-subtle', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border-subtle',
|
||||||
|
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
export function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>): React.ReactElement {
|
||||||
|
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
export function Spinner({
|
||||||
|
className,
|
||||||
|
label = '加载中'
|
||||||
|
}: {
|
||||||
|
className?: string
|
||||||
|
label?: string
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
aria-label={label}
|
||||||
|
className={cn(
|
||||||
|
'inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as SwitchPrimitive from '@radix-ui/react-switch'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-muted transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb className="pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" />
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-9 items-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center rounded-sm px-3 py-1.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-surface data-[state=active]:text-foreground data-[state=active]:shadow-surface',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'mt-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* eslint-disable react/prop-types */
|
||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
export const Textarea = React.forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-20 w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground shadow-surface transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Textarea.displayName = 'Textarea'
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as ToastPrimitive from '@radix-ui/react-toast'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
type ToastVariant = 'default' | 'success' | 'warning' | 'destructive'
|
||||||
|
type ToastItem = {
|
||||||
|
id: string
|
||||||
|
title?: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
action?: React.ReactNode
|
||||||
|
variant?: ToastVariant
|
||||||
|
duration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToastContextValue = {
|
||||||
|
toast: (input: Omit<ToastItem, 'id'>) => string
|
||||||
|
dismiss: (id?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = React.createContext<ToastContextValue | null>(null)
|
||||||
|
|
||||||
|
const variantClassNames: Record<ToastVariant, string> = {
|
||||||
|
default: 'border-border bg-surface text-foreground',
|
||||||
|
success: 'border-success/40 bg-surface text-foreground',
|
||||||
|
warning: 'border-warning/40 bg-surface text-foreground',
|
||||||
|
destructive: 'border-destructive/45 bg-surface text-foreground'
|
||||||
|
}
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Root> & { variant?: ToastVariant }
|
||||||
|
>(({ className, variant = 'default', ...props }, ref) => (
|
||||||
|
<ToastPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'group pointer-events-auto relative flex w-full items-start justify-between gap-3 overflow-hidden rounded-lg border p-4 shadow-floating duration-normal ease-tm-emphasized data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-right-full data-[state=open]:animate-in data-[state=open]:slide-in-from-top-full',
|
||||||
|
variantClassNames[variant],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Toast.displayName = ToastPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-border px-3 text-xs font-medium hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastAction.displayName = ToastPrimitive.Action.displayName
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitive.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitive.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring group-hover:opacity-100',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</ToastPrimitive.Close>
|
||||||
|
))
|
||||||
|
ToastClose.displayName = ToastPrimitive.Close.displayName
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitive.Title ref={ref} className={cn('text-sm font-semibold', className)} {...props} />
|
||||||
|
))
|
||||||
|
ToastTitle.displayName = ToastPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-xs text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastDescription.displayName = ToastPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitive.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitive.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed right-0 top-0 z-toast flex max-h-screen w-full flex-col gap-2 p-4 sm:bottom-0 sm:top-auto sm:max-w-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastViewport.displayName = ToastPrimitive.Viewport.displayName
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||||
|
const [items, setItems] = React.useState<ToastItem[]>([])
|
||||||
|
|
||||||
|
const dismiss = React.useCallback((id?: string) => {
|
||||||
|
setItems((current) => (id ? current.filter((item) => item.id !== id) : []))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toast = React.useCallback((input: Omit<ToastItem, 'id'>): string => {
|
||||||
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
setItems((current) => [...current.slice(-2), { ...input, id }])
|
||||||
|
return id
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toast, dismiss }}>
|
||||||
|
<ToastPrimitive.Provider swipeDirection="right">
|
||||||
|
{children}
|
||||||
|
{items.map((item) => (
|
||||||
|
<Toast
|
||||||
|
key={item.id}
|
||||||
|
variant={item.variant}
|
||||||
|
duration={item.duration}
|
||||||
|
onOpenChange={(open) => !open && dismiss(item.id)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{item.title ? <ToastTitle>{item.title}</ToastTitle> : null}
|
||||||
|
{item.description ? <ToastDescription>{item.description}</ToastDescription> : null}
|
||||||
|
</div>
|
||||||
|
{item.action}
|
||||||
|
<ToastClose aria-label="关闭通知" />
|
||||||
|
</Toast>
|
||||||
|
))}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastPrimitive.Provider>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function useToast(): ToastContextValue {
|
||||||
|
const context = React.useContext(ToastContext)
|
||||||
|
if (!context) throw new Error('useToast must be used within ToastProvider')
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toast, ToastAction, ToastClose, ToastTitle, ToastDescription, ToastViewport }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 5, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-popover rounded-md bg-foreground px-2.5 py-1.5 text-xs text-background shadow-floating duration-fast ease-tm-standard data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { AccountSummary } from '../../../components/account/AccountSummary'
|
import { AccountSummary } from '../../../components/account/AccountSummary'
|
||||||
|
import { Button, Input } from '../../../components/ui'
|
||||||
import { SETTINGS_NAVIGATION } from '../model/settingsNavigation'
|
import { SETTINGS_NAVIGATION } from '../model/settingsNavigation'
|
||||||
import type { SettingsCategoryId, SettingsSelfInfo } from '../model/types'
|
import type { SettingsCategoryId, SettingsSelfInfo } from '../model/types'
|
||||||
|
|
||||||
@@ -34,7 +35,8 @@ export function SettingsSidebar({
|
|||||||
<p>管理账号、数据连接和本地能力</p>
|
<p>管理账号、数据连接和本地能力</p>
|
||||||
<label className="settings-search">
|
<label className="settings-search">
|
||||||
<span>⌕</span>
|
<span>⌕</span>
|
||||||
<input
|
<Input
|
||||||
|
className="settings-search-input"
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={(event) => setKeyword(event.target.value)}
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
placeholder="搜索设置"
|
placeholder="搜索设置"
|
||||||
@@ -46,14 +48,16 @@ export function SettingsSidebar({
|
|||||||
<section key={group.label}>
|
<section key={group.label}>
|
||||||
<h2>{group.label}</h2>
|
<h2>{group.label}</h2>
|
||||||
{group.items.map((item) => (
|
{group.items.map((item) => (
|
||||||
<button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={selectedId === item.id ? 'active' : ''}
|
className={selectedId === item.id ? 'active' : ''}
|
||||||
onClick={() => onSelect(item.id)}
|
onClick={() => onSelect(item.id)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
Button
|
||||||
|
} from '../../../components/ui'
|
||||||
|
|
||||||
export function DatabaseKeyDangerZone({
|
export function DatabaseKeyDangerZone({
|
||||||
disabled,
|
disabled,
|
||||||
@@ -22,9 +33,9 @@ export function DatabaseKeyDangerZone({
|
|||||||
<strong>返回登录界面</strong>
|
<strong>返回登录界面</strong>
|
||||||
<small>断开当前数据库连接,回到密钥输入界面。不会删除已保存密钥或微信数据。</small>
|
<small>断开当前数据库连接,回到密钥输入界面。不会删除已保存密钥或微信数据。</small>
|
||||||
</span>
|
</span>
|
||||||
<button type="button" onClick={() => setConfirmingReturn(true)}>
|
<Button type="button" variant="outline" onClick={() => setConfirmingReturn(true)}>
|
||||||
返回登录
|
返回登录
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="database-key-danger">
|
<section className="database-key-danger">
|
||||||
@@ -34,91 +45,60 @@ export function DatabaseKeyDangerZone({
|
|||||||
<strong>清除已保存密钥</strong>
|
<strong>清除已保存密钥</strong>
|
||||||
<small>从系统安全存储中删除密钥,不会删除微信数据库文件。</small>
|
<small>从系统安全存储中删除密钥,不会删除微信数据库文件。</small>
|
||||||
</span>
|
</span>
|
||||||
<button type="button" onClick={() => setConfirming(true)} disabled={disabled}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
清除密钥
|
清除密钥
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>
|
<span>
|
||||||
<strong>替换当前密钥</strong>
|
<strong>替换当前密钥</strong>
|
||||||
<small>回到编辑区输入并验证新的数据库密钥。</small>
|
<small>回到编辑区输入并验证新的数据库密钥。</small>
|
||||||
</span>
|
</span>
|
||||||
<button type="button" onClick={onReplace} disabled={disabled}>
|
<Button type="button" variant="outline" onClick={onReplace} disabled={disabled}>
|
||||||
更换密钥
|
更换密钥
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{confirming && (
|
<AlertDialog open={confirming} onOpenChange={setConfirming}>
|
||||||
<div
|
<AlertDialogContent>
|
||||||
className="database-key-confirm-backdrop"
|
<AlertDialogHeader>
|
||||||
role="presentation"
|
<AlertDialogTitle>确认清除数据库密钥?</AlertDialogTitle>
|
||||||
onMouseDown={() => setConfirming(false)}
|
<AlertDialogDescription>
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="database-key-confirm"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="database-key-confirm-title"
|
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<h2 id="database-key-confirm-title">确认清除数据库密钥?</h2>
|
|
||||||
<p>
|
|
||||||
清除后 TraceMemo
|
清除后 TraceMemo
|
||||||
将暂时无法读取聊天记录,需要重新输入或获取密钥。该操作不会删除微信原始数据。
|
将暂时无法读取聊天记录,需要重新输入或获取密钥。该操作不会删除微信原始数据。
|
||||||
</p>
|
</AlertDialogDescription>
|
||||||
<div>
|
</AlertDialogHeader>
|
||||||
<button type="button" onClick={() => setConfirming(false)}>
|
<AlertDialogFooter>
|
||||||
取消
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
</button>
|
<AlertDialogAction
|
||||||
<button
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
type="button"
|
onClick={onClear}
|
||||||
className="danger"
|
|
||||||
onClick={() => {
|
|
||||||
setConfirming(false)
|
|
||||||
onClear()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
清除密钥
|
清除密钥
|
||||||
</button>
|
</AlertDialogAction>
|
||||||
</div>
|
</AlertDialogFooter>
|
||||||
</div>
|
</AlertDialogContent>
|
||||||
</div>
|
</AlertDialog>
|
||||||
)}
|
<AlertDialog open={confirmingReturn} onOpenChange={setConfirmingReturn}>
|
||||||
{confirmingReturn && (
|
<AlertDialogContent>
|
||||||
<div
|
<AlertDialogHeader>
|
||||||
className="database-key-confirm-backdrop"
|
<AlertDialogTitle>返回登录界面?</AlertDialogTitle>
|
||||||
role="presentation"
|
<AlertDialogDescription>
|
||||||
onMouseDown={() => setConfirmingReturn(false)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="database-key-confirm"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="database-key-return-confirm-title"
|
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<h2 id="database-key-return-confirm-title">返回登录界面?</h2>
|
|
||||||
<p>
|
|
||||||
TraceMemo
|
TraceMemo
|
||||||
将断开当前数据库连接并回到密钥输入界面。已保存的数据库密钥和微信原始数据不会被删除。
|
将断开当前数据库连接并回到密钥输入界面。已保存的数据库密钥和微信原始数据不会被删除。
|
||||||
</p>
|
</AlertDialogDescription>
|
||||||
<div>
|
</AlertDialogHeader>
|
||||||
<button type="button" onClick={() => setConfirmingReturn(false)}>
|
<AlertDialogFooter>
|
||||||
取消
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
</button>
|
<AlertDialogAction onClick={onReturnToLogin}>返回登录</AlertDialogAction>
|
||||||
<button
|
</AlertDialogFooter>
|
||||||
type="button"
|
</AlertDialogContent>
|
||||||
onClick={() => {
|
</AlertDialog>
|
||||||
setConfirmingReturn(false)
|
|
||||||
onReturnToLogin()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
返回登录
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { Button, IconButton, Input } from '../../../components/ui'
|
||||||
|
|
||||||
function EyeIcon({ visible }: { visible: boolean }): React.ReactElement {
|
function EyeIcon({ visible }: { visible: boolean }): React.ReactElement {
|
||||||
return visible ? (
|
return visible ? (
|
||||||
@@ -35,7 +36,7 @@ export function DatabaseKeyEditor({
|
|||||||
<section id="database-key-editor" className="settings-card database-key-editor">
|
<section id="database-key-editor" className="settings-card database-key-editor">
|
||||||
<label htmlFor="wechat-db-key">WeChat DB Key</label>
|
<label htmlFor="wechat-db-key">WeChat DB Key</label>
|
||||||
<div className="database-key-input-row">
|
<div className="database-key-input-row">
|
||||||
<input
|
<Input
|
||||||
id="wechat-db-key"
|
id="wechat-db-key"
|
||||||
type={visible ? 'text' : 'password'}
|
type={visible ? 'text' : 'password'}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -45,48 +46,59 @@ export function DatabaseKeyEditor({
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
<button
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
label={visible ? '隐藏密钥' : '显示密钥'}
|
||||||
|
tooltip={visible ? '隐藏密钥' : '显示密钥'}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setVisible((current) => !current)}
|
onClick={() => setVisible((current) => !current)}
|
||||||
title={visible ? '隐藏密钥' : '显示密钥'}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<EyeIcon visible={visible} />
|
<EyeIcon visible={visible} />
|
||||||
</button>
|
</IconButton>
|
||||||
<button type="button" onClick={onPaste} title="从剪贴板粘贴" disabled={disabled}>
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
label="从剪贴板粘贴"
|
||||||
|
onClick={onPaste}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden>
|
<svg viewBox="0 0 24 24" aria-hidden>
|
||||||
<path d="M9 5h6M9 3h6v4H9zM7 5H5v16h14V5h-2" />
|
<path d="M9 5h6M9 3h6v4H9zM7 5H5v16h14V5h-2" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</IconButton>
|
||||||
<button
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
label="清空当前输入"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange('')}
|
onClick={() => onChange('')}
|
||||||
title="清空当前输入"
|
|
||||||
disabled={disabled || !value}
|
disabled={disabled || !value}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden>
|
<svg viewBox="0 0 24 24" aria-hidden>
|
||||||
<path d="m6 6 12 12M18 6 6 18" />
|
<path d="m6 6 12 12M18 6 6 18" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
<p>保存前会验证密钥格式、数据库可读性和当前账号身份。</p>
|
<p>保存前会验证密钥格式、数据库可读性和当前账号身份。</p>
|
||||||
<div className="database-key-actions">
|
<div className="database-key-actions">
|
||||||
<button
|
<Button
|
||||||
|
variant="secondary"
|
||||||
type="button"
|
type="button"
|
||||||
className="database-key-secondary"
|
className="database-key-secondary"
|
||||||
onClick={onValidate}
|
onClick={onValidate}
|
||||||
disabled={disabled || !value}
|
disabled={disabled || !value}
|
||||||
>
|
>
|
||||||
验证密钥
|
验证密钥
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="default"
|
||||||
type="button"
|
type="button"
|
||||||
className="database-key-primary"
|
className="database-key-primary"
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={disabled || !canSave}
|
disabled={disabled || !canSave}
|
||||||
>
|
>
|
||||||
保存密钥
|
保存密钥
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
Button
|
||||||
|
} from '../../../components/ui'
|
||||||
|
|
||||||
export function DangerZone({
|
export function DangerZone({
|
||||||
disabled,
|
disabled,
|
||||||
@@ -17,31 +28,30 @@ export function DangerZone({
|
|||||||
<strong>清除图片密钥</strong>
|
<strong>清除图片密钥</strong>
|
||||||
<small>聊天记录和微信原始图片不会被删除。</small>
|
<small>聊天记录和微信原始图片不会被删除。</small>
|
||||||
</span>
|
</span>
|
||||||
<button disabled={disabled} onClick={() => setConfirming(true)}>
|
<Button variant="destructive" disabled={disabled} onClick={() => setConfirming(true)}>
|
||||||
清除图片密钥
|
清除图片密钥
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{confirming ? (
|
<AlertDialog open={confirming} onOpenChange={setConfirming}>
|
||||||
<div className="database-key-confirm-backdrop" role="presentation">
|
<AlertDialogContent>
|
||||||
<div className="database-key-confirm" role="dialog" aria-modal="true">
|
<AlertDialogHeader>
|
||||||
<h2>确认清除图片解密配置?</h2>
|
<AlertDialogTitle>确认清除图片解密配置?</AlertDialogTitle>
|
||||||
<p>清除后聊天记录仍然存在,但图片需要重新配置后才能解析。</p>
|
<AlertDialogDescription>
|
||||||
<div>
|
清除后聊天记录仍然存在,但图片需要重新配置后才能解析。
|
||||||
<button onClick={() => setConfirming(false)}>取消</button>
|
</AlertDialogDescription>
|
||||||
<button
|
</AlertDialogHeader>
|
||||||
className="danger"
|
<AlertDialogFooter>
|
||||||
onClick={() => {
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
setConfirming(false)
|
<AlertDialogAction
|
||||||
onClear()
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
}}
|
onClick={onClear}
|
||||||
>
|
>
|
||||||
清除图片密钥
|
清除图片密钥
|
||||||
</button>
|
</AlertDialogAction>
|
||||||
</div>
|
</AlertDialogFooter>
|
||||||
</div>
|
</AlertDialogContent>
|
||||||
</div>
|
</AlertDialog>
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ImageDecryptionState } from './types'
|
import type { ImageDecryptionState } from './types'
|
||||||
|
import { Input } from '../../../components/ui'
|
||||||
|
|
||||||
export function ImageKeyConfiguration({
|
export function ImageKeyConfiguration({
|
||||||
state,
|
state,
|
||||||
@@ -14,7 +15,7 @@ export function ImageKeyConfiguration({
|
|||||||
<div className="image-key-grid">
|
<div className="image-key-grid">
|
||||||
<label>
|
<label>
|
||||||
<span>XOR Key</span>
|
<span>XOR Key</span>
|
||||||
<input
|
<Input
|
||||||
value={state.xorKey}
|
value={state.xorKey}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(event) => onEdit('xorKey', event.target.value)}
|
onChange={(event) => onEdit('xorKey', event.target.value)}
|
||||||
@@ -22,7 +23,7 @@ export function ImageKeyConfiguration({
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>AES Key</span>
|
<span>AES Key</span>
|
||||||
<input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={state.aesKey}
|
value={state.aesKey}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { RadioGroup, RadioGroupItem, Switch } from '../../../components/ui'
|
||||||
|
|
||||||
export type AppearanceTheme = 'system' | 'light' | 'dark'
|
export type AppearanceTheme = 'system' | 'light' | 'dark'
|
||||||
|
|
||||||
@@ -20,7 +21,10 @@ export function AppearancePage({
|
|||||||
setTheme(result.settings.appearanceTheme)
|
setTheme(result.settings.appearanceTheme)
|
||||||
setCompactMode(result.settings.compactMode)
|
setCompactMode(result.settings.compactMode)
|
||||||
setShowStartupProgress(result.settings.showStartupProgress)
|
setShowStartupProgress(result.settings.showStartupProgress)
|
||||||
onAppearanceChange({ theme: result.settings.appearanceTheme, compactMode: result.settings.compactMode })
|
onAppearanceChange({
|
||||||
|
theme: result.settings.appearanceTheme,
|
||||||
|
compactMode: result.settings.compactMode
|
||||||
|
})
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
active = false
|
active = false
|
||||||
@@ -36,7 +40,10 @@ export function AppearancePage({
|
|||||||
setTheme(result.settings.appearanceTheme)
|
setTheme(result.settings.appearanceTheme)
|
||||||
setCompactMode(result.settings.compactMode)
|
setCompactMode(result.settings.compactMode)
|
||||||
setShowStartupProgress(result.settings.showStartupProgress)
|
setShowStartupProgress(result.settings.showStartupProgress)
|
||||||
onAppearanceChange({ theme: result.settings.appearanceTheme, compactMode: result.settings.compactMode })
|
onAppearanceChange({
|
||||||
|
theme: result.settings.appearanceTheme,
|
||||||
|
compactMode: result.settings.compactMode
|
||||||
|
})
|
||||||
onNotice('外观设置已保存')
|
onNotice('外观设置已保存')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,29 +59,63 @@ export function AppearancePage({
|
|||||||
<div className="settings-page-content">
|
<div className="settings-page-content">
|
||||||
<h2 className="settings-section-heading">显示主题</h2>
|
<h2 className="settings-section-heading">显示主题</h2>
|
||||||
<section className="settings-card settings-option-card">
|
<section className="settings-card settings-option-card">
|
||||||
<div className="settings-choice-grid">
|
<RadioGroup
|
||||||
{([
|
className="settings-choice-grid"
|
||||||
|
value={theme}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === 'system' || value === 'light' || value === 'dark')
|
||||||
|
void save({ appearanceTheme: value })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(
|
||||||
|
[
|
||||||
['system', '跟随系统', '根据 macOS 或 Windows 外观自动切换'],
|
['system', '跟随系统', '根据 macOS 或 Windows 外观自动切换'],
|
||||||
['light', '浅色', '保持当前清爽的浅色工作区'],
|
['light', '浅色', '保持当前清爽的浅色工作区'],
|
||||||
['dark', '深色', '降低夜间浏览时的亮度']
|
['dark', '深色', '降低夜间浏览时的亮度']
|
||||||
] as const).map(([value, label, hint]) => (
|
] as const
|
||||||
<label className={`settings-choice ${theme === value ? 'active' : ''}`} key={value}>
|
).map(([value, label, hint]) => (
|
||||||
<input type="radio" name="appearance-theme" checked={theme === value} onChange={() => void save({ appearanceTheme: value })} />
|
<label
|
||||||
<span><b>{label}</b><small>{hint}</small></span>
|
className={`settings-choice ${theme === value ? 'active' : ''}`}
|
||||||
|
key={value}
|
||||||
|
htmlFor={`appearance-theme-${value}`}
|
||||||
|
>
|
||||||
|
<RadioGroupItem
|
||||||
|
id={`appearance-theme-${value}`}
|
||||||
|
value={value}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<b>{label}</b>
|
||||||
|
<small>{hint}</small>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</RadioGroup>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<h2 className="settings-section-heading">工作区行为</h2>
|
<h2 className="settings-section-heading">工作区行为</h2>
|
||||||
<section className="settings-card settings-toggle-list">
|
<section className="settings-card settings-toggle-list">
|
||||||
<label className="settings-toggle-row">
|
<label className="settings-toggle-row">
|
||||||
<span><b>紧凑布局</b><small>减少导航栏和列表的留白,适合较小窗口。</small></span>
|
<span>
|
||||||
<input type="checkbox" checked={compactMode} onChange={(event) => void save({ compactMode: event.target.checked })} />
|
<b>紧凑布局</b>
|
||||||
|
<small>减少导航栏和列表的留白,适合较小窗口。</small>
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={compactMode}
|
||||||
|
onCheckedChange={(checked) => void save({ compactMode: checked })}
|
||||||
|
aria-label="紧凑布局"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-toggle-row">
|
<label className="settings-toggle-row">
|
||||||
<span><b>显示启动进度</b><small>启动或自动连接数据库时显示详细进度。</small></span>
|
<span>
|
||||||
<input type="checkbox" checked={showStartupProgress} onChange={(event) => void save({ showStartupProgress: event.target.checked })} />
|
<b>显示启动进度</b>
|
||||||
|
<small>启动或自动连接数据库时显示详细进度。</small>
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={showStartupProgress}
|
||||||
|
onCheckedChange={(checked) => void save({ showStartupProgress: checked })}
|
||||||
|
aria-label="显示启动进度"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
import { ToastProvider, TooltipProvider } from './components/ui'
|
||||||
|
import './styles/tailwind.css'
|
||||||
import './styles/index.scss'
|
import './styles/index.scss'
|
||||||
|
|
||||||
window.addEventListener('error', (event) => {
|
window.addEventListener('error', (event) => {
|
||||||
@@ -36,6 +38,10 @@ window.addEventListener('unhandledrejection', (event) => {
|
|||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<TooltipProvider>
|
||||||
|
<ToastProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
|
</TooltipProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--tm-background: 150 18% 96%;
|
||||||
|
--tm-foreground: 150 12% 15%;
|
||||||
|
--tm-surface: 0 0% 100%;
|
||||||
|
--tm-surface-muted: 150 14% 94%;
|
||||||
|
--tm-surface-elevated: 0 0% 100%;
|
||||||
|
--tm-border: 150 13% 87%;
|
||||||
|
--tm-border-subtle: 150 10% 92%;
|
||||||
|
--tm-primary: 161 54% 31%;
|
||||||
|
--tm-primary-foreground: 0 0% 100%;
|
||||||
|
--tm-secondary: 150 13% 91%;
|
||||||
|
--tm-secondary-foreground: 150 12% 20%;
|
||||||
|
--tm-muted: 150 11% 92%;
|
||||||
|
--tm-muted-foreground: 150 8% 44%;
|
||||||
|
--tm-accent: 157 39% 89%;
|
||||||
|
--tm-accent-foreground: 161 54% 25%;
|
||||||
|
--tm-success: 155 51% 37%;
|
||||||
|
--tm-warning: 34 57% 49%;
|
||||||
|
--tm-destructive: 0 51% 54%;
|
||||||
|
--tm-destructive-foreground: 0 0% 100%;
|
||||||
|
--tm-focus-ring: 161 54% 38%;
|
||||||
|
--tm-radius-sm: 0.375rem;
|
||||||
|
--tm-radius-md: 0.5rem;
|
||||||
|
--tm-radius-lg: 0.75rem;
|
||||||
|
--tm-radius-xl: 1rem;
|
||||||
|
--tm-shadow-surface: 0 1px 2px rgb(15 23 42 / 0.04);
|
||||||
|
--tm-shadow-floating: 0 8px 24px rgb(15 23 42 / 0.1);
|
||||||
|
--tm-shadow-popover: 0 12px 32px rgb(15 23 42 / 0.14);
|
||||||
|
--tm-shadow-dialog: 0 24px 80px rgb(15 23 42 / 0.2);
|
||||||
|
--tm-motion-fast: 120ms;
|
||||||
|
--tm-motion-normal: 180ms;
|
||||||
|
--tm-motion-slow: 260ms;
|
||||||
|
--tm-ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
--tm-ease-emphasized: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--tm-z-base: 0;
|
||||||
|
--tm-z-dropdown: 40;
|
||||||
|
--tm-z-popover: 50;
|
||||||
|
--tm-z-toast: 70;
|
||||||
|
--tm-z-modal: 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] {
|
||||||
|
--tm-background: 150 15% 10%;
|
||||||
|
--tm-foreground: 145 25% 94%;
|
||||||
|
--tm-surface: 150 13% 15%;
|
||||||
|
--tm-surface-muted: 150 12% 18%;
|
||||||
|
--tm-surface-elevated: 150 12% 19%;
|
||||||
|
--tm-border: 150 12% 28%;
|
||||||
|
--tm-border-subtle: 150 10% 22%;
|
||||||
|
--tm-primary: 157 48% 52%;
|
||||||
|
--tm-primary-foreground: 150 15% 10%;
|
||||||
|
--tm-secondary: 150 12% 24%;
|
||||||
|
--tm-secondary-foreground: 145 25% 94%;
|
||||||
|
--tm-muted: 150 12% 24%;
|
||||||
|
--tm-muted-foreground: 150 10% 66%;
|
||||||
|
--tm-accent: 157 31% 25%;
|
||||||
|
--tm-accent-foreground: 157 48% 82%;
|
||||||
|
--tm-success: 155 51% 55%;
|
||||||
|
--tm-warning: 34 75% 63%;
|
||||||
|
--tm-destructive: 0 65% 66%;
|
||||||
|
--tm-destructive-foreground: 150 15% 10%;
|
||||||
|
--tm-focus-ring: 157 48% 62%;
|
||||||
|
--tm-shadow-surface: 0 1px 2px rgb(0 0 0 / 0.2);
|
||||||
|
--tm-shadow-floating: 0 8px 24px rgb(0 0 0 / 0.28);
|
||||||
|
--tm-shadow-popover: 0 12px 32px rgb(0 0 0 / 0.38);
|
||||||
|
--tm-shadow-dialog: 0 24px 80px rgb(0 0 0 / 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
[data-theme='system'] {
|
||||||
|
--tm-background: 150 15% 10%;
|
||||||
|
--tm-foreground: 145 25% 94%;
|
||||||
|
--tm-surface: 150 13% 15%;
|
||||||
|
--tm-surface-muted: 150 12% 18%;
|
||||||
|
--tm-surface-elevated: 150 12% 19%;
|
||||||
|
--tm-border: 150 12% 28%;
|
||||||
|
--tm-border-subtle: 150 10% 22%;
|
||||||
|
--tm-primary: 157 48% 52%;
|
||||||
|
--tm-primary-foreground: 150 15% 10%;
|
||||||
|
--tm-secondary: 150 12% 24%;
|
||||||
|
--tm-secondary-foreground: 145 25% 94%;
|
||||||
|
--tm-muted: 150 12% 24%;
|
||||||
|
--tm-muted-foreground: 150 10% 66%;
|
||||||
|
--tm-accent: 157 31% 25%;
|
||||||
|
--tm-accent-foreground: 157 48% 82%;
|
||||||
|
--tm-success: 155 51% 55%;
|
||||||
|
--tm-warning: 34 75% 63%;
|
||||||
|
--tm-destructive: 0 65% 66%;
|
||||||
|
--tm-destructive-foreground: 150 15% 10%;
|
||||||
|
--tm-focus-ring: 157 48% 62%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(button, input, textarea, select):focus-visible {
|
||||||
|
outline: 2px solid hsl(var(--tm-focus-ring) / 0.75);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
animation-duration: 1ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 1ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: ['class', '[data-theme="dark"]'],
|
||||||
|
content: ['./src/renderer/src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
corePlugins: {
|
||||||
|
// Keep the existing SCSS application stable while new UI code adopts Tailwind incrementally.
|
||||||
|
preflight: false
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: 'hsl(var(--tm-background) / <alpha-value>)',
|
||||||
|
foreground: 'hsl(var(--tm-foreground) / <alpha-value>)',
|
||||||
|
surface: 'hsl(var(--tm-surface) / <alpha-value>)',
|
||||||
|
'surface-muted': 'hsl(var(--tm-surface-muted) / <alpha-value>)',
|
||||||
|
'surface-elevated': 'hsl(var(--tm-surface-elevated) / <alpha-value>)',
|
||||||
|
border: 'hsl(var(--tm-border) / <alpha-value>)',
|
||||||
|
'border-subtle': 'hsl(var(--tm-border-subtle) / <alpha-value>)',
|
||||||
|
primary: 'hsl(var(--tm-primary) / <alpha-value>)',
|
||||||
|
'primary-foreground': 'hsl(var(--tm-primary-foreground) / <alpha-value>)',
|
||||||
|
secondary: 'hsl(var(--tm-secondary) / <alpha-value>)',
|
||||||
|
'secondary-foreground': 'hsl(var(--tm-secondary-foreground) / <alpha-value>)',
|
||||||
|
muted: 'hsl(var(--tm-muted) / <alpha-value>)',
|
||||||
|
'muted-foreground': 'hsl(var(--tm-muted-foreground) / <alpha-value>)',
|
||||||
|
accent: 'hsl(var(--tm-accent) / <alpha-value>)',
|
||||||
|
'accent-foreground': 'hsl(var(--tm-accent-foreground) / <alpha-value>)',
|
||||||
|
success: 'hsl(var(--tm-success) / <alpha-value>)',
|
||||||
|
warning: 'hsl(var(--tm-warning) / <alpha-value>)',
|
||||||
|
destructive: 'hsl(var(--tm-destructive) / <alpha-value>)',
|
||||||
|
'destructive-foreground': 'hsl(var(--tm-destructive-foreground) / <alpha-value>)',
|
||||||
|
ring: 'hsl(var(--tm-focus-ring) / <alpha-value>)'
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
sm: 'var(--tm-radius-sm)',
|
||||||
|
md: 'var(--tm-radius-md)',
|
||||||
|
lg: 'var(--tm-radius-lg)',
|
||||||
|
xl: 'var(--tm-radius-xl)'
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
surface: 'var(--tm-shadow-surface)',
|
||||||
|
floating: 'var(--tm-shadow-floating)',
|
||||||
|
popover: 'var(--tm-shadow-popover)',
|
||||||
|
dialog: 'var(--tm-shadow-dialog)'
|
||||||
|
},
|
||||||
|
transitionDuration: {
|
||||||
|
fast: 'var(--tm-motion-fast)',
|
||||||
|
normal: 'var(--tm-motion-normal)',
|
||||||
|
slow: 'var(--tm-motion-slow)'
|
||||||
|
},
|
||||||
|
transitionTimingFunction: {
|
||||||
|
'tm-standard': 'var(--tm-ease-standard)',
|
||||||
|
'tm-emphasized': 'var(--tm-ease-emphasized)'
|
||||||
|
},
|
||||||
|
zIndex: {
|
||||||
|
base: 'var(--tm-z-base)',
|
||||||
|
dropdown: 'var(--tm-z-dropdown)',
|
||||||
|
popover: 'var(--tm-z-popover)',
|
||||||
|
toast: 'var(--tm-z-toast)',
|
||||||
|
modal: 'var(--tm-z-modal)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [require('tailwindcss-animate')]
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { AppearancePage } from '../../src/renderer/src/features/settings/pages/AppearancePage'
|
||||||
|
|
||||||
|
describe('AppearancePage UI pilot', () => {
|
||||||
|
it('uses the shared radio and switch controls without changing settings semantics', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onNotice = vi.fn()
|
||||||
|
const onAppearanceChange = vi.fn()
|
||||||
|
const settings = {
|
||||||
|
appearanceTheme: 'system' as const,
|
||||||
|
compactMode: false,
|
||||||
|
showStartupProgress: true
|
||||||
|
}
|
||||||
|
const api = {
|
||||||
|
getSettings: vi.fn(async () => ({ settings })),
|
||||||
|
setSettings: vi.fn(async (patch: Partial<typeof settings>) => ({
|
||||||
|
settings: Object.assign(settings, patch)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Object.defineProperty(window, 'api', { configurable: true, value: api })
|
||||||
|
|
||||||
|
render(<AppearancePage onNotice={onNotice} onAppearanceChange={onAppearanceChange} />)
|
||||||
|
expect(await screen.findByRole('heading', { name: '外观与行为' })).toBeVisible()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('radio', { name: /深色/ }))
|
||||||
|
await user.click(screen.getByRole('switch', { name: '紧凑布局' }))
|
||||||
|
|
||||||
|
expect(api.setSettings).toHaveBeenCalledWith({ appearanceTheme: 'dark' })
|
||||||
|
expect(api.setSettings).toHaveBeenCalledWith({ compactMode: true })
|
||||||
|
expect(onNotice).toHaveBeenCalledWith('外观设置已保存')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
Input,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
ToastProvider,
|
||||||
|
TooltipProvider,
|
||||||
|
useToast
|
||||||
|
} from '../../src/renderer/src/components/ui'
|
||||||
|
|
||||||
|
function ToastDemo(): React.ReactElement {
|
||||||
|
const { toast } = useToast()
|
||||||
|
return (
|
||||||
|
<Button onClick={() => toast({ title: '已保存', description: '设置已更新' })}>显示通知</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TraceMemo UI infrastructure', () => {
|
||||||
|
it('restores focus after a dialog is closed with Escape', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>打开对话框</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogTitle>测试对话框</DialogTitle>
|
||||||
|
<Input aria-label="测试输入" />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
|
||||||
|
const trigger = screen.getByRole('button', { name: '打开对话框' })
|
||||||
|
await user.click(trigger)
|
||||||
|
expect(screen.getByRole('dialog', { name: '测试对话框' })).toBeVisible()
|
||||||
|
|
||||||
|
await user.keyboard('{Escape}')
|
||||||
|
expect(screen.queryByRole('dialog', { name: '测试对话框' })).not.toBeInTheDocument()
|
||||||
|
expect(trigger).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes a popover when the user clicks outside', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button>打开菜单</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent>
|
||||||
|
<p>菜单内容</p>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '打开菜单' }))
|
||||||
|
expect(screen.getByText('菜单内容')).toBeVisible()
|
||||||
|
await user.click(document.body)
|
||||||
|
expect(screen.queryByText('菜单内容')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders toast notifications through the shared provider', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<ToastDemo />
|
||||||
|
</ToastProvider>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '显示通知' }))
|
||||||
|
expect(screen.getByText('已保存')).toBeVisible()
|
||||||
|
expect(screen.getByText('设置已更新')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user