> | false
18 | : T extends SimpleConfirmProperties
19 | ? boolean
20 | : never
21 |
22 | interface UIContext extends UIState {
23 | confirm: >(
24 | properties: P,
25 | ) => Promise
26 | cleanConfirmation: (index: number) => Promise
27 | }
28 |
29 | const UIContext = createContext(undefined)
30 |
31 | export const UIProvider = ({ children }: { children: React.ReactNode }) => {
32 | const [uiState, setUIState] = React.useState({
33 | confirmations: [],
34 | })
35 |
36 | const confirm = useCallback(async (properties) => {
37 | const isFormConfirm = 'form' in properties
38 |
39 | return new Promise((resolve) => {
40 | const newConirmation = isFormConfirm
41 | ? ({
42 | ...properties,
43 | open: true,
44 | onConfirm: (result) => {
45 | resolve(result)
46 | },
47 | onCancel: () => {
48 | resolve(false)
49 | },
50 | } as FormConfirmProperties)
51 | : ({
52 | ...properties,
53 | open: true,
54 | onConfirm: () => {
55 | resolve(true)
56 | },
57 | onCancel: () => {
58 | resolve(false)
59 | },
60 | } as SimpleConfirmProperties)
61 |
62 | setUIState((prevState) => {
63 | return {
64 | ...prevState,
65 | confirmations: [...prevState.confirmations, newConirmation],
66 | }
67 | })
68 | })
69 | }, [])
70 |
71 | const cleanConfirmation = useCallback(async (index: number) => {
72 | setUIState((prevState) => {
73 | const confirmations = [...prevState.confirmations]
74 | confirmations[index].open = false
75 |
76 | return {
77 | ...prevState,
78 | confirmations,
79 | }
80 | })
81 |
82 | await new Promise((resolve) => setTimeout(resolve, 200))
83 |
84 | setUIState((prevState) => {
85 | const confirmations = [...prevState.confirmations]
86 | confirmations.splice(index, 1)
87 |
88 | return {
89 | ...prevState,
90 | confirmations,
91 | }
92 | })
93 | }, [])
94 |
95 | return (
96 |
103 |
104 |
105 | {children}
106 |
107 | )
108 | }
109 |
110 | export const useConfirm = () => {
111 | const context = React.useContext(UIContext)
112 |
113 | if (context === undefined) {
114 | throw new Error('useConfirm must be used within a UIProvider')
115 | }
116 |
117 | return context.confirm
118 | }
119 |
120 | export const useConfirmations = () => {
121 | const context = React.useContext(UIContext)
122 |
123 | if (context === undefined) {
124 | throw new Error('useConfirmations must be used within a UIProvider')
125 | }
126 |
127 | return {
128 | confirmations: context.confirmations,
129 | cleanConfirmation: context.cleanConfirmation,
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/src/components/UIProvider/components/Confirmations.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react'
2 | import { useTranslation } from 'react-i18next'
3 |
4 | import {
5 | AlertDialog,
6 | AlertDialogAction,
7 | AlertDialogCancel,
8 | AlertDialogContent,
9 | AlertDialogDescription,
10 | AlertDialogFooter,
11 | AlertDialogHeader,
12 | AlertDialogTitle,
13 | } from '@/components/ui/alert-dialog'
14 |
15 | import { useConfirmations } from '../UIProvider'
16 |
17 | import { ConfirmationForm } from './ConfirmationForm'
18 |
19 | import type { SimpleConfirmProperties } from '../types'
20 |
21 | const Confirmation = ({
22 | confirmation,
23 | index,
24 | }: {
25 | confirmation: SimpleConfirmProperties
26 | index: number
27 | }) => {
28 | const { t } = useTranslation()
29 | const { cleanConfirmation } = useConfirmations()
30 |
31 | const handleAction = useCallback(
32 | (confirmation: SimpleConfirmProperties, index: number) => {
33 | if (confirmation.onConfirm) {
34 | confirmation.onConfirm()
35 | }
36 |
37 | cleanConfirmation(index)
38 | },
39 | [cleanConfirmation],
40 | )
41 |
42 | const handleCancel = useCallback(
43 | (confirmation: SimpleConfirmProperties, index: number) => {
44 | if (confirmation.onCancel) {
45 | confirmation.onCancel()
46 | }
47 |
48 | cleanConfirmation(index)
49 | },
50 | [cleanConfirmation],
51 | )
52 |
53 | return (
54 |
55 |
56 |
57 | {confirmation.title}
58 |
59 | {confirmation.description ? (
60 |
61 | {confirmation.description}
62 |
63 | ) : null}
64 |
65 |
66 |
67 | handleCancel(confirmation, index)}>
68 | {confirmation.cancelText ?? t('common.cancel')}
69 |
70 | handleAction(confirmation, index)}>
71 | {confirmation.confirmText ?? t('common.confirm')}
72 |
73 |
74 |
75 |
76 | )
77 | }
78 |
79 | const Confirmations = () => {
80 | const { confirmations } = useConfirmations()
81 |
82 | return (
83 | <>
84 | {confirmations.map((confirmation, index) =>
85 | 'form' in confirmation ? (
86 |
91 | ) : (
92 |
97 | ),
98 | )}
99 | >
100 | )
101 | }
102 |
103 | export default Confirmations
104 |
--------------------------------------------------------------------------------
/src/components/UIProvider/index.ts:
--------------------------------------------------------------------------------
1 | export { useConfirm, UIProvider } from './UIProvider'
2 |
--------------------------------------------------------------------------------
/src/components/UIProvider/types.ts:
--------------------------------------------------------------------------------
1 | import type { z, ZodObject, ZodRawShape, ZodTypeAny } from 'zod'
2 |
3 | export type SimpleConfirmProperties = {
4 | title: string
5 | description?: string
6 | confirmText?: string
7 | cancelText?: string
8 | onConfirm?: () => void
9 | onCancel?: () => void
10 | open?: boolean
11 | }
12 |
13 | export type FormConfirmProperties<
14 | Fields extends ZodRawShape = {
15 | [key: string]: ZodTypeAny
16 | },
17 | Keys extends (keyof Fields)[] = (keyof Fields)[],
18 | > = {
19 | title: string
20 | form: Fields
21 | formLabels?: Partial>
22 | formDescriptions?: Partial>
23 | formDefaultValues?: Partial>
24 | description?: string
25 | confirmText?: string
26 | cancelText?: string
27 | onConfirm?: (result: z.infer>) => void
28 | onCancel?: () => void
29 | open?: boolean
30 | }
31 |
32 | export type ConfirmProperties = SimpleConfirmProperties | FormConfirmProperties
33 |
--------------------------------------------------------------------------------
/src/components/VersionSupport.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { useVersionSupport } from '@/hooks/useVersionSupport'
4 |
5 | interface VersionSupportProps {
6 | macos?: string | boolean
7 | ios?: string | boolean
8 | tvos?: string | boolean
9 | children: React.ReactNode
10 | }
11 |
12 | const VersionSupport: React.FC = ({
13 | macos,
14 | ios,
15 | tvos,
16 | children,
17 | }) => {
18 | const isSupported = useVersionSupport({ macos, ios, tvos })
19 |
20 | if (isSupported) {
21 | return <>{children}>
22 | }
23 |
24 | return null
25 | }
26 |
27 | export default VersionSupport
28 |
--------------------------------------------------------------------------------
/src/components/VersionTag.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { usePlatform, usePlatformBuild, usePlatformVersion } from '@/store'
4 |
5 | const VersionTag = () => {
6 | const platform = usePlatform()
7 | const platformVersion = usePlatformVersion()
8 | const platformBuild = usePlatformBuild()
9 |
10 | const isPlatformInfoShown = Boolean(
11 | platform && platformBuild && platformVersion,
12 | )
13 |
14 | const content = isPlatformInfoShown
15 | ? `v${process.env.REACT_APP_VERSION}` +
16 | '\n' +
17 | `${platform} v${platformVersion} (${platformBuild})`
18 | : `v${process.env.REACT_APP_VERSION}`
19 |
20 | return (
21 |
22 | {content}
23 |
24 | )
25 | }
26 |
27 | export default VersionTag
28 |
--------------------------------------------------------------------------------
/src/components/VerticalSafeArea.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { css } from '@emotion/react'
3 |
4 | export const BottomSafeArea = () => {
5 | return (
6 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { cva, type VariantProps } from 'class-variance-authority'
3 |
4 | import { cn } from '@/utils/shadcn'
5 |
6 | const alertVariants = cva(
7 | 'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
8 | {
9 | variants: {
10 | variant: {
11 | default: 'bg-background text-foreground',
12 | destructive:
13 | 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
14 | },
15 | },
16 | defaultVariants: {
17 | variant: 'default',
18 | },
19 | },
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = 'Alert'
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = 'AlertTitle'
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = 'AlertDescription'
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { cva, type VariantProps } from 'class-variance-authority'
3 |
4 | import { cn } from '@/utils/shadcn'
5 |
6 | const badgeVariants = cva(
7 | 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
13 | secondary:
14 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
15 | destructive:
16 | 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
17 | outline: 'text-foreground',
18 | },
19 | },
20 | defaultVariants: {
21 | variant: 'default',
22 | },
23 | },
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/src/components/ui/button-group.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { cva, VariantProps } from 'class-variance-authority'
3 |
4 | import { cn } from '@/utils/shadcn'
5 |
6 | const variants = cva('flex items-center space-x-3', {
7 | variants: {
8 | align: {
9 | left: 'justify-start',
10 | center: 'justify-center',
11 | right: 'justify-end',
12 | },
13 | },
14 | defaultVariants: {
15 | align: 'left',
16 | },
17 | })
18 |
19 | type ButtonGroupProps = React.HTMLAttributes &
20 | VariantProps
21 |
22 | const ButtonGroup = React.forwardRef(
23 | ({ children, className, align, ...props }, ref) => (
24 |
34 | {children}
35 |
36 | ),
37 | )
38 |
39 | ButtonGroup.displayName = 'ButtonGroup'
40 |
41 | export { ButtonGroup }
42 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { css } from '@emotion/react'
3 | import { Slot } from '@radix-ui/react-slot'
4 | import { cva, type VariantProps } from 'class-variance-authority'
5 | import { Loader2 } from 'lucide-react'
6 | import tw from 'twin.macro'
7 |
8 | import { cn } from '@/utils/shadcn'
9 |
10 | const buttonVariants = cva(
11 | 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
12 | {
13 | variants: {
14 | variant: {
15 | default:
16 | 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
17 | destructive:
18 | 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
19 | outline:
20 | 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
21 | secondary:
22 | 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
23 | ghost: 'hover:bg-accent hover:text-accent-foreground',
24 | link: 'text-primary underline-offset-4 hover:underline',
25 | },
26 | size: {
27 | default: 'h-9 px-4 py-2',
28 | sm: 'h-8 rounded-md px-3 text-xs',
29 | lg: 'h-10 rounded-md px-8',
30 | icon: 'h-9 w-9',
31 | },
32 | stretch: {
33 | true: 'w-full',
34 | false: '',
35 | },
36 | },
37 | defaultVariants: {
38 | variant: 'default',
39 | size: 'default',
40 | },
41 | },
42 | )
43 |
44 | export interface ButtonProps
45 | extends React.ButtonHTMLAttributes,
46 | VariantProps {
47 | asChild?: boolean
48 | isLoading?: boolean
49 | loadingLabel?: string
50 | stretch?: boolean
51 | }
52 |
53 | const Button = React.forwardRef(
54 | (
55 | {
56 | className,
57 | variant,
58 | size,
59 | asChild = false,
60 | isLoading,
61 | loadingLabel,
62 | stretch,
63 | ...props
64 | },
65 | ref,
66 | ) => {
67 | const Comp = asChild ? Slot : 'button'
68 |
69 | if (isLoading) {
70 | return (
71 |
77 |
78 | {loadingLabel}
79 |
80 | )
81 | }
82 |
83 | return (
84 | * {
90 | ${tw`w-4 h-4`};
91 | }
92 | `,
93 | ]}
94 | ref={ref}
95 | {...props}
96 | />
97 | )
98 | },
99 | )
100 | Button.displayName = 'Button'
101 |
102 | export { Button, buttonVariants }
103 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@/utils/shadcn'
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
14 | ))
15 | Card.displayName = 'Card'
16 |
17 | const CardHeader = React.forwardRef<
18 | HTMLDivElement,
19 | React.HTMLAttributes
20 | >(({ className, ...props }, ref) => (
21 |
26 | ))
27 | CardHeader.displayName = 'CardHeader'
28 |
29 | const CardTitle = React.forwardRef<
30 | HTMLParagraphElement,
31 | React.HTMLAttributes
32 | >(({ className, ...props }, ref) => (
33 |
38 | ))
39 | CardTitle.displayName = 'CardTitle'
40 |
41 | const CardDescription = React.forwardRef<
42 | HTMLParagraphElement,
43 | React.HTMLAttributes
44 | >(({ className, ...props }, ref) => (
45 |
50 | ))
51 | CardDescription.displayName = 'CardDescription'
52 |
53 | const CardContent = React.forwardRef<
54 | HTMLDivElement,
55 | React.HTMLAttributes
56 | >(({ className, ...props }, ref) => (
57 |
58 | ))
59 | CardContent.displayName = 'CardContent'
60 |
61 | const CardFooter = React.forwardRef<
62 | HTMLDivElement,
63 | React.HTMLAttributes
64 | >(({ className, ...props }, ref) => (
65 |
70 | ))
71 | CardFooter.displayName = 'CardFooter'
72 |
73 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
74 |
--------------------------------------------------------------------------------
/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
3 | import { CheckIcon } from '@radix-ui/react-icons'
4 |
5 | import { cn } from '@/utils/shadcn'
6 |
7 | const Checkbox = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => (
11 |
19 |
22 |
23 |
24 |
25 | ))
26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
27 |
28 | export { Checkbox }
29 |
--------------------------------------------------------------------------------
/src/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Drawer as DrawerPrimitive } from 'vaul'
3 |
4 | import { cn } from '@/utils/shadcn'
5 |
6 | const Drawer = ({
7 | shouldScaleBackground = true,
8 | ...props
9 | }: React.ComponentProps) => (
10 |
14 | )
15 | Drawer.displayName = 'Drawer'
16 |
17 | const DrawerTrigger = DrawerPrimitive.Trigger
18 |
19 | const DrawerPortal = DrawerPrimitive.Portal
20 |
21 | const DrawerClose = DrawerPrimitive.Close
22 |
23 | const DrawerOverlay = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
34 |
35 | const DrawerContent = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, children, ...props }, ref) => (
39 |
40 |
41 |
49 |
50 | {children}
51 |
52 |
53 | ))
54 | DrawerContent.displayName = 'DrawerContent'
55 |
56 | const DrawerHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
64 | )
65 | DrawerHeader.displayName = 'DrawerHeader'
66 |
67 | const DrawerFooter = ({
68 | className,
69 | ...props
70 | }: React.HTMLAttributes) => (
71 |
75 | )
76 | DrawerFooter.displayName = 'DrawerFooter'
77 |
78 | const DrawerTitle = React.forwardRef<
79 | React.ElementRef,
80 | React.ComponentPropsWithoutRef
81 | >(({ className, ...props }, ref) => (
82 |
90 | ))
91 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName
92 |
93 | const DrawerDescription = React.forwardRef<
94 | React.ElementRef,
95 | React.ComponentPropsWithoutRef
96 | >(({ className, ...props }, ref) => (
97 |
102 | ))
103 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName
104 |
105 | export {
106 | Drawer,
107 | DrawerPortal,
108 | DrawerOverlay,
109 | DrawerTrigger,
110 | DrawerClose,
111 | DrawerContent,
112 | DrawerHeader,
113 | DrawerFooter,
114 | DrawerTitle,
115 | DrawerDescription,
116 | }
117 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@/utils/shadcn'
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | },
22 | )
23 | Input.displayName = 'Input'
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as LabelPrimitive from '@radix-ui/react-label'
3 | import { cva, type VariantProps } from 'class-variance-authority'
4 |
5 | import { cn } from '@/utils/shadcn'
6 |
7 | const labelVariants = cva(
8 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
9 | )
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ))
22 | Label.displayName = LabelPrimitive.Root.displayName
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as PopoverPrimitive from '@radix-ui/react-popover'
3 |
4 | import { cn } from '@/utils/shadcn'
5 |
6 | const Popover = PopoverPrimitive.Root
7 |
8 | const PopoverTrigger = PopoverPrimitive.Trigger
9 |
10 | const PopoverContent = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
14 |
15 |
25 |
26 | ))
27 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
28 |
29 | export { Popover, PopoverTrigger, PopoverContent }
30 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as SwitchPrimitives from '@radix-ui/react-switch'
3 |
4 | import { cn } from '@/utils/shadcn'
5 |
6 | const Switch = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
23 |
24 | ))
25 | Switch.displayName = SwitchPrimitives.Root.displayName
26 |
27 | export { Switch }
28 |
--------------------------------------------------------------------------------
/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as TabsPrimitive from '@radix-ui/react-tabs'
3 |
4 | import { cn } from '@/utils/shadcn'
5 |
6 | const Tabs = TabsPrimitive.Root
7 |
8 | const TabsList = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | TabsList.displayName = TabsPrimitive.List.displayName
22 |
23 | const TabsTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
35 | ))
36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
37 |
38 | const TabsContent = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
50 | ))
51 | TabsContent.displayName = TabsPrimitive.Content.displayName
52 |
53 | export { Tabs, TabsList, TabsTrigger, TabsContent }
54 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@/utils/shadcn'
4 |
5 | const Textarea = React.forwardRef<
6 | HTMLTextAreaElement,
7 | React.ComponentProps<'textarea'>
8 | >(({ className, ...props }, ref) => {
9 | return (
10 |
18 | )
19 | })
20 | Textarea.displayName = 'Textarea'
21 |
22 | export { Textarea }
23 |
--------------------------------------------------------------------------------
/src/components/ui/toggle.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as TogglePrimitive from '@radix-ui/react-toggle'
3 | import { cva, type VariantProps } from 'class-variance-authority'
4 |
5 | import { cn } from '@/utils/shadcn'
6 |
7 | const toggleVariants = cva(
8 | 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
9 | {
10 | variants: {
11 | variant: {
12 | default: 'bg-transparent',
13 | outline:
14 | 'border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground',
15 | },
16 | size: {
17 | default: 'h-9 px-3',
18 | sm: 'h-8 px-2',
19 | lg: 'h-10 px-3',
20 | },
21 | },
22 | defaultVariants: {
23 | variant: 'default',
24 | size: 'default',
25 | },
26 | },
27 | )
28 |
29 | const Toggle = React.forwardRef<
30 | React.ElementRef,
31 | React.ComponentPropsWithoutRef &
32 | VariantProps
33 | >(({ className, variant, size, ...props }, ref) => (
34 |
39 | ))
40 |
41 | Toggle.displayName = TogglePrimitive.Root.displayName
42 |
43 | export { Toggle, toggleVariants }
44 |
--------------------------------------------------------------------------------
/src/components/ui/typography.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { cn } from '@/utils/shadcn'
4 |
5 | export function TypographyH1({
6 | children,
7 | className,
8 | ...props
9 | }: { children: React.ReactNode } & React.HTMLAttributes) {
10 | return (
11 |
18 | {children}
19 |
20 | )
21 | }
22 |
23 | export function TypographyH2({
24 | children,
25 | className,
26 | ...props
27 | }: { children: React.ReactNode } & React.HTMLAttributes) {
28 | return (
29 |
36 | {children}
37 |
38 | )
39 | }
40 |
41 | export function TypographyH3({
42 | children,
43 | className,
44 | ...props
45 | }: { children: React.ReactNode } & React.HTMLAttributes) {
46 | return (
47 |
48 | {children}
49 |
50 | )
51 | }
52 |
53 | export function TypographyH4({
54 | children,
55 | className,
56 | ...props
57 | }: { children: React.ReactNode } & React.HTMLAttributes) {
58 | return (
59 |
60 | {children}
61 |
62 | )
63 | }
64 |
65 | export function TypographyP({
66 | children,
67 | className,
68 | ...props
69 | }: { children: React.ReactNode } & React.HTMLAttributes) {
70 | return (
71 |
75 | {children}
76 |
77 | )
78 | }
79 |
80 | export function TypographyBlockquote({
81 | children,
82 | className,
83 | ...props
84 | }: {
85 | children: React.ReactNode
86 | } & React.HTMLAttributes) {
87 | return (
88 |
92 | {children}
93 |
94 | )
95 | }
96 |
97 | export function TypographySmall({
98 | children,
99 | className,
100 | ...props
101 | }: { children: React.ReactNode } & React.HTMLAttributes) {
102 | return (
103 |
107 | {children}
108 |
109 | )
110 | }
111 |
112 | export function TypographyMuted({
113 | children,
114 | className,
115 | ...props
116 | }: { children: React.ReactNode } & React.HTMLAttributes) {
117 | return (
118 |
119 | {children}
120 |
121 | )
122 | }
123 |
--------------------------------------------------------------------------------
/src/data/api.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr'
2 |
3 | import { useVersionSupport } from '@/hooks/useVersionSupport'
4 | import fetcher from '@/utils/fetcher'
5 |
6 | export const useCurrentProfile = () =>
7 | useSWR<{
8 | name: string
9 | profile: string
10 | originalProfile: string
11 | }>('/profiles/current?sensitive=1', fetcher, {
12 | revalidateOnFocus: false,
13 | revalidateOnReconnect: false,
14 | })
15 |
16 | export const useAvailableProfiles = () => {
17 | const isProfileManagementSupport = useVersionSupport({
18 | macos: '4.0.6',
19 | })
20 | return useSWR<{
21 | profiles: string[]
22 | }>(isProfileManagementSupport ? '/profiles' : null, fetcher, {
23 | revalidateOnFocus: false,
24 | revalidateOnReconnect: false,
25 | })
26 | }
27 |
28 | export const useProfileValidation = (name: string | undefined) => {
29 | const isProfileManagementSupport = useVersionSupport({
30 | macos: '4.0.6',
31 | })
32 | const resultFetcher = (props: [string, string]) =>
33 | fetcher<{ error: string | null }>({
34 | url: props[0],
35 | method: 'POST',
36 | data: {
37 | name: props[1],
38 | },
39 | timeout: 30_000,
40 | })
41 |
42 | return useSWR(
43 | isProfileManagementSupport && name ? ['/profiles/check', name] : null,
44 | resultFetcher,
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/src/data/index.ts:
--------------------------------------------------------------------------------
1 | export * from './api'
2 |
--------------------------------------------------------------------------------
/src/hooks/useSafeAreaInsets/context.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from 'react'
2 |
3 | export type Context = {
4 | top: number
5 | left: number
6 | right: number
7 | bottom: number
8 | }
9 |
10 | const context = createContext(null)
11 |
12 | export default context
13 |
--------------------------------------------------------------------------------
/src/hooks/useSafeAreaInsets/hooks.ts:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import context from './context'
4 |
5 | export const useSafeAreaInsets = () => {
6 | const safeAreaInsets = React.useContext(context)
7 |
8 | if (safeAreaInsets === null) {
9 | throw new Error(
10 | 'useSafeAreaInsets must be used within a SafeAreaInsetsProvider',
11 | )
12 | }
13 |
14 | return safeAreaInsets
15 | }
16 |
--------------------------------------------------------------------------------
/src/hooks/useSafeAreaInsets/index.ts:
--------------------------------------------------------------------------------
1 | export * from './provider'
2 | export * from './hooks'
3 |
--------------------------------------------------------------------------------
/src/hooks/useSafeAreaInsets/provider.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react'
2 |
3 | import context, { type Context } from './context'
4 |
5 | type SafeAreaInsetsProviderProps = {
6 | children: React.ReactNode
7 | }
8 |
9 | export const SafeAreaInsetsProvider = ({
10 | children,
11 | }: SafeAreaInsetsProviderProps) => {
12 | const [state, setState] = useState(null)
13 |
14 | useEffect(() => {
15 | const tempDiv = document.createElement('div')
16 |
17 | tempDiv.style.paddingTop = 'env(safe-area-inset-top, 0px)'
18 | tempDiv.style.paddingBottom = 'env(safe-area-inset-bottom, 0px)'
19 | tempDiv.style.paddingLeft = 'env(safe-area-inset-left, 0px)'
20 | tempDiv.style.paddingRight = 'env(safe-area-inset-right, 0px)'
21 | tempDiv.style.position = 'absolute'
22 | tempDiv.style.visibility = 'hidden'
23 |
24 | document.body.appendChild(tempDiv)
25 |
26 | const safeAreaInsetTop = window.getComputedStyle(tempDiv).paddingTop
27 | const safeAreaInsetBottom = window.getComputedStyle(tempDiv).paddingBottom
28 | const safeAreaInsetLeft = window.getComputedStyle(tempDiv).paddingLeft
29 | const safeAreaInsetRight = window.getComputedStyle(tempDiv).paddingRight
30 |
31 | document.body.removeChild(tempDiv)
32 |
33 | setState({
34 | top: parseInt(safeAreaInsetTop.replace('px', ''), 10),
35 | bottom: parseInt(safeAreaInsetBottom.replace('px', ''), 10),
36 | left: parseInt(safeAreaInsetLeft.replace('px', ''), 10),
37 | right: parseInt(safeAreaInsetRight.replace('px', ''), 10),
38 | })
39 | }, [])
40 |
41 | return {children}
42 | }
43 |
--------------------------------------------------------------------------------
/src/hooks/useTrafficUpdater/constants.ts:
--------------------------------------------------------------------------------
1 | export const REFRESH_RATE = 1000
2 |
3 | export const BACKGROUND_REFRESH_RATE = 3000
4 |
--------------------------------------------------------------------------------
/src/hooks/useTrafficUpdater/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './useTrafficUpdater'
2 | export { REFRESH_RATE } from './constants'
3 |
--------------------------------------------------------------------------------
/src/hooks/useTrafficUpdater/useTrafficUpdater.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import { useLocation } from 'react-router-dom'
3 | import dayjs from 'dayjs'
4 |
5 | import { useAppDispatch, useProfile } from '@/store'
6 | import { trafficActions } from '@/store/slices/traffic'
7 | import { ConnectorTraffic, Traffic } from '@/types'
8 | import fetcher from '@/utils/fetcher'
9 |
10 | import { REFRESH_RATE } from './constants'
11 |
12 | const useTrafficUpdater = () => {
13 | const dispatch = useAppDispatch()
14 | const profile = useProfile()
15 | const location = useLocation()
16 |
17 | const isInForeground =
18 | location.pathname === '/traffic' || location.pathname === '/home'
19 |
20 | useEffect(() => {
21 | if (profile === undefined) return
22 |
23 | const fetchTraffic = () => {
24 | fetcher({ url: '/traffic' }).then(
25 | (res) => {
26 | res.nowTime = Date.now()
27 | dispatch(trafficActions.updateConnector(res.connector))
28 | dispatch(trafficActions.updateInterface(res.interface))
29 | dispatch(
30 | trafficActions.updateStartTime(
31 | dayjs.unix(res.startTime).toDate().getTime(),
32 | ),
33 | )
34 |
35 | const aggregation: ConnectorTraffic = {
36 | outCurrentSpeed: 0,
37 | in: 0,
38 | inCurrentSpeed: 0,
39 | outMaxSpeed: 0,
40 | out: 0,
41 | inMaxSpeed: 0,
42 | }
43 |
44 | for (const name in res.interface) {
45 | const conn = res.interface[name]
46 | aggregation.in += conn.in
47 | aggregation.out += conn.out
48 | aggregation.outCurrentSpeed += conn.outCurrentSpeed
49 | aggregation.inCurrentSpeed += conn.inCurrentSpeed
50 | }
51 |
52 | const now = Date.now()
53 | dispatch(
54 | trafficActions.updateHistory({
55 | up: {
56 | x: now,
57 | y: aggregation.outCurrentSpeed,
58 | },
59 | down: {
60 | x: now,
61 | y: aggregation.inCurrentSpeed,
62 | },
63 | }),
64 | )
65 | },
66 | )
67 | }
68 |
69 | const intervalId = setInterval(() => {
70 | if (isInForeground) {
71 | fetchTraffic()
72 | }
73 | }, REFRESH_RATE)
74 |
75 | return () => clearInterval(intervalId)
76 | }, [dispatch, profile, isInForeground])
77 |
78 | return undefined
79 | }
80 |
81 | export default useTrafficUpdater
82 |
--------------------------------------------------------------------------------
/src/hooks/useVersionSupport/index.ts:
--------------------------------------------------------------------------------
1 | export * from './useVersionSupport'
2 |
--------------------------------------------------------------------------------
/src/hooks/useVersionSupport/useVersionSupport.spec.ts:
--------------------------------------------------------------------------------
1 | import { jest } from '@jest/globals'
2 | import { renderHook } from '@testing-library/react-hooks'
3 |
4 | jest.unstable_mockModule('@/store', () => ({
5 | usePlatform: jest.fn(),
6 | usePlatformVersion: jest.fn(),
7 | }))
8 |
9 | const { usePlatform, usePlatformVersion } = await import('@/store')
10 | const { useVersionSupport } = await import('./useVersionSupport')
11 |
12 | describe('useVersionSupport', () => {
13 | beforeEach(() => {
14 | jest.clearAllMocks()
15 | })
16 |
17 | describe.each`
18 | platform | platformVersion | macos | ios | tvos | expected
19 | ${'macos'} | ${'10.15.0'} | ${'10.14.0'} | ${undefined} | ${undefined} | ${true}
20 | ${'macos'} | ${'10.15.0'} | ${'10.16.0'} | ${undefined} | ${undefined} | ${false}
21 | ${'ios'} | ${'10.15.0'} | ${'10.14.0'} | ${undefined} | ${undefined} | ${false}
22 | ${'ios'} | ${'10.15.0'} | ${undefined} | ${true} | ${undefined} | ${true}
23 | ${'ios'} | ${'10.15.0'} | ${true} | ${true} | ${true} | ${true}
24 | `(
25 | 'when platform is $platform and platformVersion is $platformVersion and macos is $macos and ios is $ios and tvos is $tvos',
26 | ({ platform, platformVersion, macos, ios, tvos, expected }: any) => {
27 | beforeEach(() => {
28 | ;(usePlatform as jest.Mock).mockReturnValue(platform)
29 | ;(usePlatformVersion as jest.Mock).mockReturnValue(platformVersion)
30 | })
31 |
32 | it('should work', () => {
33 | const { result } = renderHook(() =>
34 | useVersionSupport({ macos, ios, tvos }),
35 | )
36 |
37 | expect(result.current).toBe(expected)
38 | })
39 | },
40 | )
41 | })
42 |
--------------------------------------------------------------------------------
/src/hooks/useVersionSupport/useVersionSupport.ts:
--------------------------------------------------------------------------------
1 | import gte from 'semver/functions/gte'
2 |
3 | import { usePlatform, usePlatformVersion } from '@/store'
4 |
5 | interface VersionSupportProps {
6 | macos?: string | boolean
7 | ios?: string | boolean
8 | tvos?: string | boolean
9 | }
10 |
11 | export const useVersionSupport = ({
12 | macos,
13 | ios,
14 | tvos,
15 | }: VersionSupportProps): boolean => {
16 | const platform = usePlatform()
17 | const platformVersion = usePlatformVersion()
18 |
19 | if (!platform || !platformVersion) {
20 | return false
21 | }
22 |
23 | if (
24 | macos &&
25 | platform === 'macos' &&
26 | gte(platformVersion, parseVersion(macos))
27 | ) {
28 | return true
29 | }
30 |
31 | if (ios && platform === 'ios' && gte(platformVersion, parseVersion(ios))) {
32 | return true
33 | }
34 |
35 | if (tvos && platform === 'tvos' && gte(platformVersion, parseVersion(tvos))) {
36 | return true
37 | }
38 |
39 | return false
40 | }
41 |
42 | function parseVersion(version: string | boolean): string {
43 | if (typeof version === 'string') {
44 | return version
45 | }
46 | return '0.0.0'
47 | }
48 |
--------------------------------------------------------------------------------
/src/i18n/index.ts:
--------------------------------------------------------------------------------
1 | import { initReactI18next } from 'react-i18next'
2 | import i18n from 'i18next'
3 | import ChainedBackend from 'i18next-chained-backend'
4 | import resourcesToBackend from 'i18next-resources-to-backend'
5 |
6 | i18n
7 | .use(initReactI18next) // passes i18n down to react-i18next
8 | .use(ChainedBackend)
9 | .init({
10 | // debug: true,
11 | lng: navigator.language.substr(0, 2),
12 | fallbackLng: 'en',
13 | supportedLngs: ['en', 'zh'],
14 | nonExplicitSupportedLngs: true,
15 | interpolation: {
16 | escapeValue: false, // react already safes from xss
17 | },
18 | backend: {
19 | backends: [
20 | resourcesToBackend((lng, ns, clb) => {
21 | import(`./${lng}/${ns}.json`)
22 | .then((resources) => clb(null, resources))
23 | .catch((err) => clb(err, undefined))
24 | }),
25 | ],
26 | },
27 | })
28 |
29 | export default i18n
30 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { toast } from 'react-hot-toast'
3 | import ReactDOM from 'react-dom/client'
4 |
5 | import './styles/shadcn.css'
6 | import './styles/global.css'
7 |
8 | import SWUpdateNotification from '@/components/SWUpdateNotification'
9 | import { RouterProvider } from '@/router'
10 |
11 | import routes from './routes'
12 | import * as serviceWorkerRegistration from './serviceWorkerRegistration'
13 | import './i18n'
14 |
15 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
16 |
17 | root.render(
18 |
19 |
20 | ,
21 | )
22 |
23 | if (process.env.REACT_APP_USE_SW === 'true') {
24 | // If you want your app to work offline and load faster, you can change
25 | // unregister() to register() below. Note this comes with some pitfalls.
26 | // Learn more about service workers: https://cra.link/PWA
27 | serviceWorkerRegistration.register({
28 | onUpdate: (registration) => {
29 | toast(() => , {
30 | duration: Number.POSITIVE_INFINITY,
31 | })
32 | },
33 | })
34 | }
35 |
36 | if (!('scrollBehavior' in document.documentElement.style)) {
37 | // @ts-ignore
38 | import('smoothscroll-polyfill').then((mod) => {
39 | mod.polyfill()
40 | })
41 | }
42 |
--------------------------------------------------------------------------------
/src/pages/Devices/components/DeviceIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { css } from '@emotion/react'
3 |
4 | import { useSurgeHost } from '@/store'
5 |
6 | interface DeviceIconProps {
7 | icon?: string
8 | }
9 |
10 | const DeviceIcon = ({ icon }: DeviceIconProps): JSX.Element => {
11 | const surgeHost = useSurgeHost()
12 |
13 | return (
14 |
21 |
26 |
27 | )
28 | }
29 |
30 | export default DeviceIcon
31 |
--------------------------------------------------------------------------------
/src/pages/Devices/components/schemas.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import { z } from 'zod'
4 |
5 | export const useDeviceSettingsSchema = () => {
6 | const { t } = useTranslation()
7 |
8 | const DeviceSettingsSchema = useMemo(
9 | () =>
10 | z.object({
11 | name: z.string().trim().optional(),
12 | address: z
13 | .string()
14 | .trim()
15 | .ip({
16 | version: 'v4',
17 | message: t('devices.err_not_ip'),
18 | }),
19 | shouldHandledBySurge: z.boolean(),
20 | }),
21 | [t],
22 | )
23 |
24 | return DeviceSettingsSchema
25 | }
26 |
--------------------------------------------------------------------------------
/src/pages/Devices/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import useSWR from 'swr'
4 |
5 | import { ListCell, ListFullHeightCell } from '@/components/ListCell'
6 | import PageTitle from '@/components/PageTitle'
7 | import { DevicesResult } from '@/types'
8 | import fetcher from '@/utils/fetcher'
9 | import withProfile from '@/utils/with-profile'
10 |
11 | import DeviceItem from './components/DeviceItem'
12 |
13 | const ComponentBase = (): JSX.Element => {
14 | const { t } = useTranslation()
15 | const [isAutoRefresh, setIsAutoRefresh] = useState(false)
16 | const { data: devices } = useSWR('/devices', fetcher, {
17 | revalidateOnFocus: false,
18 | revalidateOnReconnect: false,
19 | refreshInterval: isAutoRefresh ? 2000 : 0,
20 | })
21 |
22 | const deviceList = devices?.devices.length ? (
23 | devices.devices.map((device) => (
24 |
25 |
26 |
27 | ))
28 | ) : (
29 | {t('devices.empty_list')}
30 | )
31 |
32 | return (
33 | <>
34 | setIsAutoRefresh(newState)}
39 | />
40 |
41 |
42 | {!devices ? (
43 |
44 | {t('common.is_loading') + '...'}
45 |
46 | ) : (
47 | deviceList
48 | )}
49 |
50 | >
51 | )
52 | }
53 |
54 | export const Component = withProfile(ComponentBase)
55 |
56 | Component.displayName = 'DevicesPage'
57 |
58 | export { ErrorBoundary } from '@/components/ErrorBoundary'
59 |
--------------------------------------------------------------------------------
/src/pages/Home/components/CapabilityTile.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import { useNavigate } from 'react-router-dom'
4 | import useSWR, { mutate } from 'swr'
5 |
6 | import { Switch } from '@/components/ui/switch'
7 | import { useProfile } from '@/store'
8 | import { Capability } from '@/types'
9 | import fetcher from '@/utils/fetcher'
10 |
11 | import MenuTile from './MenuTile'
12 |
13 | interface CapabilityTileProps {
14 | api: string
15 | titleKey: string
16 | descriptionKey?: string
17 | link?: string
18 | }
19 |
20 | const CapabilityTile: React.FC = ({
21 | api,
22 | titleKey,
23 | descriptionKey,
24 | link,
25 | }) => {
26 | const { t } = useTranslation()
27 | const profile = useProfile()
28 | const { data: capability, isLoading } = useSWR(
29 | profile !== undefined ? api : null,
30 | fetcher,
31 | { revalidateOnFocus: false, revalidateOnReconnect: false },
32 | )
33 | const navigate = useNavigate()
34 |
35 | const toggle = useCallback(
36 | (newVal: boolean) => {
37 | fetcher({
38 | method: 'POST',
39 | url: api,
40 | data: {
41 | enabled: newVal,
42 | },
43 | })
44 | .then(() => {
45 | return mutate(api)
46 | })
47 | .catch((err) => {
48 | console.error(err)
49 | })
50 | },
51 | [api],
52 | )
53 |
54 | return (
55 | navigate(link) : undefined}
57 | title={t(`home.${titleKey}`)}
58 | description={descriptionKey ? t(`home.${descriptionKey}`) : undefined}
59 | switchElement={
60 | toggle(newVal)}
64 | />
65 | }
66 | />
67 | )
68 | }
69 |
70 | export default CapabilityTile
71 |
--------------------------------------------------------------------------------
/src/pages/Home/components/Events.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import { css } from '@emotion/react'
4 | import dayjs from 'dayjs'
5 | import localizedFormat from 'dayjs/plugin/localizedFormat'
6 | import useSWR from 'swr'
7 |
8 | import { StatusChip } from '@/components/StatusChip'
9 | import { TypographyH4 } from '@/components/ui/typography'
10 | import { useProfile } from '@/store'
11 | import { EventList } from '@/types'
12 | import fetcher from '@/utils/fetcher'
13 |
14 | dayjs.extend(localizedFormat)
15 |
16 | const Events: React.FC = () => {
17 | const { t } = useTranslation()
18 | const profile = useProfile()
19 | const { data: events } = useSWR(
20 | profile !== undefined ? '/events' : null,
21 | fetcher,
22 | )
23 |
24 | return (
25 |
26 |
{t('home.events')}
27 |
28 |
29 |
30 | {events &&
31 | events.events.slice(0, 8).map((item) => {
32 | return (
33 |
34 |
41 | {item.content}
42 |
43 |
44 |
45 | {item.type === 2 && (
46 |
47 | )}
48 | {item.type === 1 && (
49 |
50 | )}
51 | {item.type === 0 && (
52 |
53 | )}
54 |
55 | {dayjs(item.date).format('L LTS')}
56 |
57 |
58 | )
59 | })}
60 |
61 |
62 |
63 | )
64 | }
65 |
66 | export default Events
67 |
--------------------------------------------------------------------------------
/src/pages/Home/components/HostInfo.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import ProfileCell from '@/components/ProfileCell'
4 | import { useProfile } from '@/store'
5 |
6 | const HostInfo = (): JSX.Element => {
7 | const profile = useProfile()
8 |
9 | return (
10 |
13 | )
14 | }
15 |
16 | export default HostInfo
17 |
--------------------------------------------------------------------------------
/src/pages/Home/components/MenuTile.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { css } from '@emotion/react'
3 | import styled from '@emotion/styled'
4 | import { ChevronRight } from 'lucide-react'
5 |
6 | import { Button } from '@/components/ui/button'
7 | import {
8 | Card,
9 | CardHeader,
10 | CardContent,
11 | CardDescription,
12 | CardTitle,
13 | } from '@/components/ui/card'
14 | import { TypographyH4 } from '@/components/ui/typography'
15 |
16 | interface MenuTileProps {
17 | title: string
18 | description?: string
19 | onClick?: () => void
20 | link?: string
21 | switchElement?: React.ReactNode
22 | }
23 |
24 | const MenuTile: React.FC = (props) => {
25 | const handleClick = () => {
26 | if (props.onClick) {
27 | props.onClick()
28 | }
29 | }
30 |
31 | return (
32 |
33 |
39 |
40 | {props.title}
41 | {props.switchElement}
42 |
43 |
44 |
45 |
46 |
47 | {props.description ? (
48 |
49 | {props.description}
50 |
51 | ) : (
52 |
53 | )}
54 |
55 | {props.onClick ? (
56 |
57 | handleClick()}
61 | >
62 |
63 |
64 |
65 | ) : null}
66 |
67 |
68 |
69 | )
70 | }
71 |
72 | export const MenuTileContent = styled.div``
73 |
74 | export const MenuTileTitle: React.FC<{ title: string }> = ({ title }) => {
75 | return {title}
76 | }
77 |
78 | export default MenuTile
79 |
--------------------------------------------------------------------------------
/src/pages/Home/components/SetHostModal.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useEffect, useState } from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import { useNavigate } from 'react-router-dom'
4 | import { find } from 'lodash-es'
5 | import { Laptop } from 'lucide-react'
6 | import store from 'store2'
7 |
8 | import ProfileCell from '@/components/ProfileCell'
9 | import { useResponsiveDialog } from '@/components/ResponsiveDialog'
10 | import { Badge } from '@/components/ui/badge'
11 | import { Button } from '@/components/ui/button'
12 | import { useAppDispatch, useProfile } from '@/store'
13 | import { profileActions } from '@/store/slices/profile'
14 | import { trafficActions } from '@/store/slices/traffic'
15 | import { Profile } from '@/types'
16 | import { ExistingProfiles, LastUsedProfile } from '@/utils/constant'
17 |
18 | const SetHostModal: React.FC = () => {
19 | const { t } = useTranslation()
20 | const dispatch = useAppDispatch()
21 |
22 | const {
23 | Dialog,
24 | DialogContent,
25 | DialogHeader,
26 | DialogTitle,
27 | DialogFooter,
28 | DialogTrigger,
29 | DialogClose,
30 | DialogDescription,
31 | } = useResponsiveDialog()
32 |
33 | const [existingProfiles, setExistingProfiles] = useState>([])
34 | const currentProfile = useProfile()
35 | const navigate = useNavigate()
36 |
37 | const selectProfile = useCallback(
38 | (id: string) => {
39 | const profile = find(existingProfiles, { id })
40 |
41 | if (profile) {
42 | store.set(LastUsedProfile, profile.id)
43 | window.location.reload()
44 | }
45 | },
46 | [existingProfiles],
47 | )
48 |
49 | const onAddNewProfile = useCallback(() => {
50 | dispatch(profileActions.clear())
51 | dispatch(trafficActions.clear())
52 | navigate('/', { replace: true })
53 | }, [dispatch, navigate])
54 |
55 | useEffect(() => {
56 | const storedExistingProfiles = store.get(ExistingProfiles)
57 |
58 | if (storedExistingProfiles) {
59 | setExistingProfiles(storedExistingProfiles)
60 | }
61 | }, [])
62 |
63 | return (
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | {t('landing.history')}
74 |
75 | {t('landing.history')}
76 |
77 |
78 |
79 |
80 | {existingProfiles.map((profile) => {
81 | return (
82 |
86 | {profile.id === currentProfile?.id && (
87 |
88 | {t('landing.current')}
89 |
90 | )}
91 |
92 |
selectProfile(profile.id)}
96 | />
97 |
98 |
99 | )
100 | })}
101 |
102 |
103 |
104 | onAddNewProfile()}>
105 | {t('landing.add_new_host')}
106 |
107 |
108 |
109 | {t('common.close')}
110 |
111 |
112 |
113 |
114 |
115 | )
116 | }
117 |
118 | export default SetHostModal
119 |
--------------------------------------------------------------------------------
/src/pages/Home/components/TrafficCell/TrafficCell.tsx:
--------------------------------------------------------------------------------
1 | import React, { lazy, Suspense, useMemo } from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import { css } from '@emotion/react'
4 | import bytes from 'bytes'
5 | import tw from 'twin.macro'
6 |
7 | import { useInterfaces } from '@/store'
8 | import { ConnectorTraffic } from '@/types'
9 |
10 | const LineChart = lazy(() => import('./components/LineChart'))
11 | const Cell = tw.div`px-4 py-3`
12 | const Title = tw.div`text-xs text-muted-foreground leading-relaxed`
13 | const Data = tw.div`text-base md:text-lg text-gray-700 dark:text-white/90 font-bold leading-normal tabular-nums`
14 |
15 | const LineChartLoader = () => (
16 |
22 | Loading...
23 |
24 | )
25 |
26 | const TrafficCell: React.FC = () => {
27 | const { t } = useTranslation()
28 | const interfaces = useInterfaces()
29 |
30 | const activeInterface = useMemo(() => {
31 | const aggregation: ConnectorTraffic = {
32 | outCurrentSpeed: 0,
33 | in: 0,
34 | inCurrentSpeed: 0,
35 | outMaxSpeed: 0,
36 | out: 0,
37 | inMaxSpeed: 0,
38 | }
39 |
40 | for (const name in interfaces) {
41 | const conn = interfaces[name]
42 | aggregation.in += conn.in
43 | aggregation.out += conn.out
44 | aggregation.outCurrentSpeed += conn.outCurrentSpeed
45 | aggregation.inCurrentSpeed += conn.inCurrentSpeed
46 | }
47 |
48 | return aggregation
49 | }, [interfaces])
50 |
51 | const betterSpeedString = (speed: number, isCircular: boolean = true) => {
52 | const readableString = bytes(speed, {
53 | unitSeparator: '---',
54 | })
55 | const [value, unit] = readableString.split('---')
56 |
57 | return (
58 | <>
59 | {value}
60 | {' ' + unit + (isCircular ? '/s' : '')}
61 | >
62 | )
63 | }
64 |
65 | return (
66 |
67 |
68 | }>
69 |
70 |
71 |
72 |
73 | {activeInterface ? (
74 |
75 |
80 | {t('traffic_cell.upload')}
81 |
82 | {betterSpeedString(activeInterface.outCurrentSpeed)}
83 |
84 | |
85 |
90 | {t('traffic_cell.download')}
91 |
92 | {betterSpeedString(activeInterface.inCurrentSpeed)}
93 |
94 | |
95 |
100 | {t('traffic_cell.total')}
101 |
102 | {betterSpeedString(
103 | activeInterface.in + activeInterface.out,
104 | false,
105 | )}
106 |
107 | |
108 |
109 | ) : (
110 |
118 | {t('common.is_loading')}...
119 |
120 | )}
121 |
122 | )
123 | }
124 |
125 | export default TrafficCell
126 |
--------------------------------------------------------------------------------
/src/pages/Home/components/TrafficCell/chart-config.ts:
--------------------------------------------------------------------------------
1 | import bytes from 'bytes'
2 | import { ChartOptions } from 'chart.js'
3 |
4 | import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm'
5 |
6 | export const commonChartOptions: ChartOptions<'line'> = {
7 | responsive: true,
8 | maintainAspectRatio: false,
9 | plugins: {
10 | title: {
11 | display: false,
12 | },
13 | legend: {
14 | display: true,
15 | position: 'bottom',
16 | labels: {
17 | color: '#ccc',
18 | boxWidth: 20,
19 | },
20 | },
21 | tooltip: {
22 | enabled: false,
23 | },
24 | },
25 | hover: {
26 | mode: 'nearest',
27 | intersect: true,
28 | },
29 | animation: {
30 | duration: 400,
31 | },
32 | scales: {
33 | x: {
34 | type: 'timeseries',
35 | display: false,
36 | reverse: false,
37 | ticks: {
38 | autoSkip: false,
39 | },
40 | },
41 | y: {
42 | display: true,
43 | beginAtZero: true,
44 | grid: {
45 | display: true,
46 | color: 'hsl(0, 0%, 84%)',
47 | drawTicks: false,
48 | },
49 | position: 'right',
50 | border: {
51 | dash: [3, 6],
52 | display: false,
53 | },
54 | ticks: {
55 | callback(value): string {
56 | return (
57 | bytes(value as number, { decimalPlaces: 0, unitSeparator: ' ' }) +
58 | '/s '
59 | )
60 | },
61 | maxTicksLimit: 4,
62 | },
63 | },
64 | },
65 | }
66 |
67 | export const chartStyles = {
68 | up: {
69 | borderColor: '#8250ff',
70 | borderWidth: 2,
71 | lineTension: 0.3,
72 | pointRadius: 0,
73 | backgroundColor: 'hsl(280, 75%, 98%)',
74 | },
75 | down: {
76 | borderColor: '#06b5f4',
77 | borderWidth: 2,
78 | lineTension: 0.3,
79 | pointRadius: 0,
80 | backgroundColor: 'hsl(210, 100%, 98%)',
81 | },
82 | }
83 |
--------------------------------------------------------------------------------
/src/pages/Home/components/TrafficCell/components/LineChart.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react'
2 | import { Line } from 'react-chartjs-2'
3 | import { css } from '@emotion/react'
4 | import {
5 | Chart,
6 | TimeSeriesScale,
7 | Tooltip,
8 | Legend,
9 | LinearScale,
10 | PointElement,
11 | LineElement,
12 | } from 'chart.js'
13 | import set from 'lodash-es/set'
14 |
15 | import { useTrafficHistory } from '@/store'
16 | import { DataPoint } from '@/types'
17 |
18 | import { chartStyles, commonChartOptions } from '../chart-config'
19 | import { CHART_SIZE } from '../constants'
20 |
21 | Chart.register(
22 | TimeSeriesScale,
23 | LinearScale,
24 | Tooltip,
25 | Legend,
26 | PointElement,
27 | LineElement,
28 | )
29 |
30 | const LineChart: React.FC = () => {
31 | const trafficHistory = useTrafficHistory()
32 | const chartRef = useRef | null>(null)
33 | const chartData = useRef<{
34 | up: DataPoint[]
35 | down: DataPoint[]
36 | }>({
37 | up: [],
38 | down: [],
39 | })
40 |
41 | useEffect(() => {
42 | chartData.current.up = []
43 | chartData.current.down = []
44 | }, [])
45 |
46 | useEffect(() => {
47 | set(
48 | chartRef,
49 | 'current.config.options.scales.x.min',
50 | trafficHistory.up[CHART_SIZE].x,
51 | )
52 |
53 | if (chartData.current.up.length === 0) {
54 | chartData.current.up = [...trafficHistory.up]
55 | chartData.current.down = [...trafficHistory.down]
56 | } else {
57 | chartData.current.up.unshift(trafficHistory.up[0])
58 | chartData.current.down.unshift(trafficHistory.down[0])
59 | }
60 | }, [trafficHistory])
61 |
62 | return (
63 |
68 |
87 |
88 | )
89 | }
90 |
91 | export default LineChart
92 |
--------------------------------------------------------------------------------
/src/pages/Home/components/TrafficCell/constants.ts:
--------------------------------------------------------------------------------
1 | export const CHART_SIZE = 50
2 |
--------------------------------------------------------------------------------
/src/pages/Home/components/TrafficCell/index.tsx:
--------------------------------------------------------------------------------
1 | export { default } from './TrafficCell'
2 |
--------------------------------------------------------------------------------
/src/pages/Home/menu.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import gte from 'semver/functions/gte'
3 |
4 | import { Profile } from '@/types'
5 | import { isRunInSurge } from '@/utils'
6 |
7 | import CapabilityTile from './components/CapabilityTile'
8 |
9 | export interface MenuItem {
10 | titleKey: string
11 | descriptionKey?: string
12 | link?: string
13 | component?: JSX.Element
14 | isEnabled?: (
15 | platform: Profile['platform'] | void,
16 | platformVersion: Profile['platformVersion'] | void,
17 | ) => boolean
18 | }
19 |
20 | const menu: Array = [
21 | {
22 | titleKey: 'policies',
23 | link: '/policies',
24 | },
25 | {
26 | titleKey: 'requests',
27 | link: '/requests',
28 | },
29 | {
30 | titleKey: 'traffic',
31 | descriptionKey: 'descriptions.traffic',
32 | link: '/traffic',
33 | },
34 | {
35 | titleKey: 'scripting',
36 | component: (
37 |
43 | ),
44 | },
45 | {
46 | titleKey: 'modules',
47 | link: '/modules',
48 | descriptionKey: 'descriptions.modules',
49 | isEnabled: (platform) => platform !== 'tvos',
50 | },
51 | {
52 | titleKey: 'device_management',
53 | link: '/devices',
54 | descriptionKey: 'descriptions.device_management',
55 | isEnabled: (platform, platformVersion) => {
56 | return Boolean(
57 | platform === 'macos' &&
58 | platformVersion &&
59 | gte(platformVersion, '4.0.6'),
60 | )
61 | },
62 | },
63 | {
64 | titleKey: 'dns',
65 | descriptionKey: 'descriptions.dns',
66 | link: '/dns',
67 | },
68 | {
69 | titleKey: 'profile',
70 | link: '/profiles',
71 | descriptionKey: 'descriptions.profile',
72 | },
73 | {
74 | titleKey: 'mitm',
75 | component: (
76 |
81 | ),
82 | },
83 | {
84 | titleKey: 'http_capture',
85 | component: (
86 |
91 | ),
92 | },
93 | {
94 | titleKey: 'rewrite',
95 | component: (
96 |
101 | ),
102 | },
103 | ]
104 |
105 | if (!isRunInSurge()) {
106 | menu.push({
107 | titleKey: 'github',
108 | descriptionKey: 'descriptions.github',
109 | link: 'https://github.com/geekdada/yasd',
110 | })
111 | }
112 |
113 | export default menu
114 |
--------------------------------------------------------------------------------
/src/pages/Landing/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { TypographyH3 } from '@/components/ui/typography'
4 |
5 | export default function Header(): JSX.Element {
6 | return (
7 |
8 | Surge Web Dashboard
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/src/pages/Landing/components/HeaderInfo.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const HeaderInfo = () => {
4 | return (
5 |
6 |
7 | 该功能仅 Surge iOS 4.4.0 和 Surge Mac 4.0.0 以上版本支持。
8 |
9 |
10 |
16 | 🔗 开启方式
17 |
18 |
19 |
20 | )
21 | }
22 |
23 | export default HeaderInfo
24 |
--------------------------------------------------------------------------------
/src/pages/Landing/components/InstallCertificateModal.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import tw from 'twin.macro'
4 |
5 | import { Button } from '@/components/ui/button'
6 | import { Dialog, DialogContent, DialogHeader } from '@/components/ui/dialog'
7 | import { TypographyP } from '@/components/ui/typography'
8 |
9 | const Li = tw.li`mt-2 space-y-2`
10 |
11 | const InstallCertificateModal: React.FC<{
12 | origin?: string
13 | accessKey?: string
14 | open: boolean
15 | onOpenChange: (newState: boolean) => void
16 | }> = ({ origin, accessKey, open, onOpenChange }) => {
17 | const { t } = useTranslation()
18 | const downloadUrl = useMemo(() => {
19 | if (!accessKey || !origin) return undefined
20 |
21 | const u = new URL('/v1/mitm/ca', origin)
22 |
23 | u.searchParams.set('x-key', accessKey)
24 |
25 | return u
26 | }, [origin, accessKey])
27 |
28 | if (!downloadUrl) return null
29 |
30 | return (
31 |
32 |
33 |
34 | {t('tls_instruction.title')}
35 |
36 |
37 |
{t('tls_instruction.description')}
38 |
39 |
40 |
41 | {t('tls_instruction.instruction1')}
42 |
51 |
52 |
53 | {t('tls_instruction.instruction2')}
54 |
55 |
56 | {t('tls_instruction.instruction3')}
57 |
58 |
59 | {t('tls_instruction.instruction4')}
60 |
61 |
62 | {t('tls_instruction.instruction5')}
63 |
64 |
65 |
66 |
67 |
68 | )
69 | }
70 |
71 | export default InstallCertificateModal
72 |
--------------------------------------------------------------------------------
/src/pages/Landing/hooks.ts:
--------------------------------------------------------------------------------
1 | import { useState, useMemo } from 'react'
2 | import { useForm } from 'react-hook-form'
3 | import { useTranslation } from 'react-i18next'
4 | import { zodResolver } from '@hookform/resolvers/zod'
5 | import { z } from 'zod'
6 |
7 | import { getSurgeHost } from '@/pages/Landing/utils'
8 | import { isRunInSurge } from '@/utils'
9 |
10 | const useSchemas = () => {
11 | const { t } = useTranslation()
12 | const RegularLoginFormSchema = useMemo(
13 | () =>
14 | z.object({
15 | name: z.string().min(1, {
16 | message: t('devices.err_required'),
17 | }),
18 | host: z.string().min(1, {
19 | message: t('devices.err_required'),
20 | }),
21 | port: z.number().min(1).max(65535),
22 | key: z.string().min(1, {
23 | message: t('devices.err_required'),
24 | }),
25 | useTls: z.boolean(),
26 | keepCredential: z.boolean(),
27 | }),
28 | [t],
29 | )
30 |
31 | return {
32 | RegularLoginFormSchema,
33 | }
34 | }
35 |
36 | export const useAuthData = () => {
37 | const [isLoading, setIsLoading] = useState(false)
38 | const [tlsInstruction, setTlsInstruction] = useState<{
39 | origin?: string
40 | accessKey?: string
41 | open: boolean
42 | }>({
43 | open: false,
44 | })
45 |
46 | return {
47 | isLoading,
48 | setIsLoading,
49 | tlsInstruction,
50 | setTlsInstruction,
51 | }
52 | }
53 |
54 | export const useLoginForm = () => {
55 | const { RegularLoginFormSchema } = useSchemas()
56 | const surgeHost = getSurgeHost()
57 | const defaultValues = isRunInSurge()
58 | ? ({
59 | name: 'Surge',
60 | host: surgeHost.hostname,
61 | port: Number(surgeHost.port),
62 | key: '',
63 | keepCredential: true,
64 | useTls: surgeHost.protocol === 'https:',
65 | } as const)
66 | : ({
67 | name: '',
68 | host: '',
69 | port: 6171,
70 | key: '',
71 | keepCredential: true,
72 | useTls: window.location.protocol === 'https:',
73 | } as const)
74 | const regularForm = useForm>({
75 | resolver: zodResolver(RegularLoginFormSchema),
76 | defaultValues,
77 | })
78 |
79 | return { form: regularForm, Schema: RegularLoginFormSchema }
80 | }
81 |
--------------------------------------------------------------------------------
/src/pages/Landing/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './Regular'
2 |
--------------------------------------------------------------------------------
/src/pages/Landing/types.ts:
--------------------------------------------------------------------------------
1 | export interface SurgeFormFields extends FormFields {
2 | key: string
3 | }
4 |
5 | export interface RegularFormFields extends FormFields {
6 | name: string
7 | host: string
8 | port: number
9 | key: string
10 | useTls: boolean
11 | }
12 |
13 | export interface FormFields {
14 | keepCredential: boolean
15 | }
16 |
--------------------------------------------------------------------------------
/src/pages/Landing/utils.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 |
3 | export const tryHost = async (
4 | protocol: string,
5 | hostname: string,
6 | port: string | number,
7 | key: string,
8 | ): Promise<{
9 | name?: string
10 | platform: 'macos' | 'ios'
11 | platformVersion: string
12 | platformBuild: string
13 | }> => {
14 | const basicInfoReq = axios.request({
15 | url: `${protocol}//${hostname}:${port}/v1/outbound`,
16 | method: 'GET',
17 | timeout: 3000,
18 | headers: {
19 | 'x-key': key,
20 | },
21 | responseType: 'json',
22 | })
23 | const environmentReq = axios
24 | .request<{ deviceName: string }>({
25 | url: `${protocol}//${hostname}:${port}/v1/environment`,
26 | method: 'GET',
27 | timeout: 3000,
28 | headers: {
29 | 'x-key': key,
30 | },
31 | responseType: 'json',
32 | })
33 | .then((res) => res.data)
34 | .catch(() => undefined)
35 | const [basicInfo, environment] = await Promise.all([
36 | basicInfoReq,
37 | environmentReq,
38 | ])
39 |
40 | return {
41 | name: environment ? environment.deviceName : undefined,
42 | platform: (basicInfo.headers['x-system'] || '').toLowerCase(),
43 | platformVersion: basicInfo.headers['x-surge-version'] || '',
44 | platformBuild: basicInfo.headers['x-surge-build'] || '',
45 | }
46 | }
47 |
48 | export const getSurgeHost = (): {
49 | protocol: string
50 | hostname: string
51 | port: string
52 | } => {
53 | if (process.env.NODE_ENV === 'production') {
54 | const protocol = window.location.protocol
55 | const port = window.location.port
56 | ? window.location.port
57 | : protocol === 'https:'
58 | ? '443'
59 | : '80'
60 |
61 | return {
62 | protocol,
63 | hostname: window.location.hostname,
64 | port,
65 | }
66 | }
67 |
68 | return {
69 | protocol: process.env.REACT_APP_PROTOCOL as string,
70 | hostname: process.env.REACT_APP_HOST as string,
71 | port: process.env.REACT_APP_PORT as string,
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/pages/Modules/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useState } from 'react'
2 | import { toast } from 'react-hot-toast'
3 | import { useTranslation } from 'react-i18next'
4 | import useSWR, { mutate } from 'swr'
5 |
6 | import { ListCell, ListFullHeightCell } from '@/components/ListCell'
7 | import PageTitle from '@/components/PageTitle'
8 | import { Switch } from '@/components/ui/switch'
9 | import { Modules } from '@/types'
10 | import fetcher from '@/utils/fetcher'
11 | import withProfile from '@/utils/with-profile'
12 |
13 | const ComponentBase: React.FC = () => {
14 | const { t } = useTranslation()
15 | const { data: modules } = useSWR('/modules', fetcher)
16 | const [isLoading, setIsLoading] = useState(false)
17 |
18 | const isChecked = (name: string): boolean => {
19 | return modules?.enabled.includes(name) === true
20 | }
21 |
22 | const toggle = useCallback(
23 | (name: string, newVal: boolean) => {
24 | setIsLoading(true)
25 |
26 | fetcher({
27 | url: '/modules',
28 | method: 'POST',
29 | data: {
30 | [name]: newVal,
31 | },
32 | })
33 | .then(() => {
34 | toast.success(t('common.success_interaction'))
35 | return mutate('/modules')
36 | })
37 | .catch((err) => {
38 | toast.success(t('common.failed_interaction'))
39 | console.error(err)
40 | })
41 | .finally(() => {
42 | setIsLoading(false)
43 | })
44 | },
45 | [setIsLoading, t],
46 | )
47 |
48 | return (
49 | <>
50 |
51 |
52 |
53 | {modules &&
54 | modules.available.map((mod) => {
55 | return (
56 |
60 | {mod}
61 |
62 | toggle(mod, checked)}
66 | />
67 |
68 |
69 | )
70 | })}
71 |
72 | {modules && modules.available.length === 0 && (
73 |
{t('modules.empty_list')}
74 | )}
75 |
76 | >
77 | )
78 | }
79 |
80 | export const Component = withProfile(ComponentBase)
81 |
82 | Component.displayName = 'ModulesPage'
83 |
84 | export { ErrorBoundary } from '@/components/ErrorBoundary'
85 |
--------------------------------------------------------------------------------
/src/pages/Policies/components/PolicyNameItem.tsx:
--------------------------------------------------------------------------------
1 | import tw from 'twin.macro'
2 |
3 | export const PolicyNameItem = tw.div`
4 | flex-shrink-0 bg-muted dark:bg-background rounded-xl border px-2 sm:px-3 py-2 overflow-hidden cursor-pointer shadow-sm font-bold
5 | hover:bg-gray-100 dark:hover:bg-black/90 transition-colors ease-in-out duration-200
6 | text-xs sm:text-sm select-none
7 | `
8 |
--------------------------------------------------------------------------------
/src/pages/Policies/usePolicyPerformance.ts:
--------------------------------------------------------------------------------
1 | import useSWR, { mutate } from 'swr'
2 |
3 | import { useVersionSupport } from '@/hooks/useVersionSupport'
4 | import { PolicyBenchmarkResults } from '@/types'
5 | import fetcher from '@/utils/fetcher'
6 |
7 | export const mutatePolicyPerformanceResults = () =>
8 | mutate('/policies/benchmark_results')
9 |
10 | export const usePolicyPerformance = () => {
11 | const isSupported = useVersionSupport({
12 | ios: '4.9.5',
13 | macos: '4.2.4',
14 | })
15 | const { data, error } = useSWR(
16 | isSupported ? '/policies/benchmark_results' : null,
17 | fetcher,
18 | {
19 | revalidateOnFocus: false,
20 | revalidateOnReconnect: false,
21 | refreshInterval: 0,
22 | },
23 | )
24 |
25 | return {
26 | data,
27 | error,
28 | mutate: mutatePolicyPerformanceResults,
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/pages/Profiles/Current/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { lazy, Suspense, useMemo } from 'react'
2 | import { useTranslation } from 'react-i18next'
3 |
4 | import BottomPanel from '@/components/BottomPanel'
5 | import CodeMirrorLoading from '@/components/CodeMirrorLoading'
6 | import PageTitle from '@/components/PageTitle'
7 | import { Toggle } from '@/components/ui/toggle'
8 | import { useCurrentProfile } from '@/data'
9 | import withProfile from '@/utils/with-profile'
10 |
11 | const CodeMirror = lazy(() => import('@/components/CodeMirror'))
12 |
13 | const ComponentBase: React.FC = () => {
14 | const { t } = useTranslation()
15 | const [version, setVersion] = React.useState<'original' | 'processed'>(
16 | 'processed',
17 | )
18 | const { data: profile, isLoading } = useCurrentProfile()
19 | const profileString = useMemo(() => {
20 | if (!profile) {
21 | return undefined
22 | }
23 |
24 | return version === 'processed' ? profile.profile : profile.originalProfile
25 | }, [profile, version])
26 |
27 | return (
28 | <>
29 |
30 |
31 |
32 |
33 | }>
34 |
38 |
39 |
40 |
41 |
42 |
43 | {
47 | if (pressed) {
48 | setVersion('processed')
49 | }
50 | }}
51 | >
52 | {t('profiles.version_processed')}
53 |
54 |
55 | {
59 | if (pressed) {
60 | setVersion('original')
61 | }
62 | }}
63 | >
64 | {t('profiles.version_original')}
65 |
66 |
67 |
68 |
69 | >
70 | )
71 | }
72 |
73 | export const Component = withProfile(ComponentBase)
74 |
75 | Component.displayName = 'CurrentProfilePage'
76 |
77 | export { ErrorBoundary } from '@/components/ErrorBoundary'
78 |
--------------------------------------------------------------------------------
/src/pages/Profiles/Manage/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Manage'
2 |
--------------------------------------------------------------------------------
/src/pages/Requests/components/ListItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import { css } from '@emotion/react'
4 | import bytes from 'bytes'
5 | import dayjs from 'dayjs'
6 |
7 | import { RequestItem } from '@/types'
8 |
9 | import MethodBadge from './MethodBadge'
10 |
11 | const ListItem: React.FC<{ req: RequestItem }> = ({ req }) => {
12 | const { t } = useTranslation()
13 |
14 | const formatStatusKey = (str: string): string =>
15 | str.toLowerCase().replace(/\s/g, '_')
16 |
17 | return (
18 | <>
19 | {req.URL}
20 |
26 |
31 |
#{req.id}
32 |
33 | -
34 | {dayjs.unix(req.startDate).format('HH:mm:ss')}
35 |
36 | {req.policyName ? (
37 |
38 | -
39 | {req.policyName}
40 |
41 | ) : null}
42 |
43 | -
44 | {bytes(req.inBytes + req.outBytes)}
45 |
46 | {req.status ? (
47 |
48 | -
49 | {t(`requests.${formatStatusKey(req.status)}`)}
50 |
51 | ) : null}
52 |
53 | >
54 | )
55 | }
56 |
57 | export default ListItem
58 |
--------------------------------------------------------------------------------
/src/pages/Requests/components/MethodBadge.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { css } from '@emotion/react'
3 | import tw from 'twin.macro'
4 |
5 | import { isTruthy } from '@/utils'
6 | import { cn } from '@/utils/shadcn'
7 |
8 | type MethodBadgeProps = {
9 | failed: 1 | 0 | boolean
10 | method: string
11 | status: string
12 | } & React.HTMLAttributes
13 |
14 | const MethodBadge: React.FC = ({
15 | failed,
16 | method,
17 | status,
18 | className,
19 | css: cssProp,
20 | ...args
21 | }) => {
22 | return (
23 |
40 | {method.toUpperCase()}
41 |
42 | )
43 | }
44 |
45 | export default MethodBadge
46 |
--------------------------------------------------------------------------------
/src/pages/Requests/hooks/filters.ts:
--------------------------------------------------------------------------------
1 | import { RequestItem } from '@/types'
2 |
3 | import { SorterRules } from '../components/SorterPopover'
4 |
5 | export const activeFilter = (
6 | enabled: boolean | undefined,
7 | item: RequestItem,
8 | ): boolean => {
9 | if (enabled) {
10 | return !item.completed
11 | }
12 | return true
13 | }
14 |
15 | export const sourceIpFilter = (
16 | ip: string | null | undefined,
17 | item: RequestItem,
18 | ): boolean => {
19 | if (ip) {
20 | return item.sourceAddress === ip
21 | }
22 | return true
23 | }
24 |
25 | export const urlFilter = (
26 | url: string | undefined,
27 | item: RequestItem,
28 | ): boolean => {
29 | if (url) {
30 | return item.URL.includes(url)
31 | }
32 | return true
33 | }
34 |
35 | export const sorter = (
36 | sortRule: SorterRules,
37 | a: RequestItem,
38 | b: RequestItem,
39 | ): number => {
40 | if (sortRule.sortBy === null) {
41 | return 0
42 | }
43 |
44 | if (sortRule.sortBy === 'time') {
45 | // Return comparing a.startDate and b.startDate
46 | return sortRule.sortDirection === 'asc'
47 | ? a.startDate - b.startDate
48 | : b.startDate - a.startDate
49 | }
50 |
51 | if (sortRule.sortBy === 'size') {
52 | // Return comparing a.inBytes + a.outBytes and b.inBytes + b.outBytes
53 | return sortRule.sortDirection === 'asc'
54 | ? a.inBytes + a.outBytes - b.inBytes - b.outBytes
55 | : b.inBytes + b.outBytes - a.inBytes - a.outBytes
56 | }
57 |
58 | return 0
59 | }
60 |
--------------------------------------------------------------------------------
/src/pages/Requests/hooks/reducer.ts:
--------------------------------------------------------------------------------
1 | import { Reducer, useReducer } from 'react'
2 |
3 | import { RequestItem } from '@/types'
4 |
5 | enum RequestListActions {
6 | LOAD_REQUESTS = 'LOAD_REQUESTS',
7 | UPDATE_REQUEST = 'UPDATE_REQUEST',
8 | RESET = 'RESET',
9 | }
10 |
11 | type RequestListState = {
12 | requestList: RequestItem[] | undefined
13 | lastUpdated?: Date
14 | }
15 |
16 | type RequestListReducerAction =
17 | | {
18 | type: RequestListActions.LOAD_REQUESTS
19 | payload: RequestItem[]
20 | }
21 | | {
22 | type: RequestListActions.UPDATE_REQUEST
23 | payload: RequestItem
24 | }
25 | | {
26 | type: RequestListActions.RESET
27 | }
28 |
29 | const requestListReducer: Reducer<
30 | RequestListState,
31 | RequestListReducerAction
32 | > = (state, action) => {
33 | switch (action.type) {
34 | case RequestListActions.LOAD_REQUESTS: {
35 | const currentList = state.requestList ? [...state.requestList] : []
36 | const newItems: RequestItem[] = []
37 | const insertMethod = currentList.length > 0 ? 'unshift' : 'push'
38 |
39 | for (const request of action.payload) {
40 | const index = currentList.findIndex((item) => item.id === request.id)
41 | const now = new Date()
42 |
43 | if (index === -1) {
44 | newItems.push({
45 | ...request,
46 | lastUpdated: now,
47 | })
48 | } else {
49 | Object.assign(currentList[index], {
50 | ...request,
51 | lastUpdated: now,
52 | })
53 | }
54 | }
55 |
56 | currentList[insertMethod](...newItems)
57 |
58 | return {
59 | ...state,
60 | requestList: currentList,
61 | lastUpdated: new Date(),
62 | }
63 | }
64 |
65 | case RequestListActions.UPDATE_REQUEST:
66 | if (!state.requestList) {
67 | return state
68 | }
69 |
70 | return {
71 | ...state,
72 | requestList: state.requestList.map((request) => {
73 | if (request.id === action.payload.id) {
74 | return action.payload
75 | }
76 | return request
77 | }),
78 | lastUpdated: new Date(),
79 | }
80 |
81 | case RequestListActions.RESET:
82 | return {
83 | ...state,
84 | requestList: [],
85 | lastUpdated: new Date(),
86 | }
87 | default:
88 | throw new Error(`Unknown action type: ${action}`)
89 | }
90 | }
91 |
92 | const useRequestListReducer = () =>
93 | useReducer(requestListReducer, {
94 | requestList: undefined,
95 | lastUpdated: undefined,
96 | })
97 |
98 | export { useRequestListReducer, RequestListActions }
99 |
--------------------------------------------------------------------------------
/src/pages/Requests/hooks/useRequestsList.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useMemo } from 'react'
2 | import useSWR from 'swr'
3 |
4 | import { SorterRules } from '@/pages/Requests/components/SorterPopover'
5 | import {
6 | activeFilter,
7 | sorter,
8 | sourceIpFilter,
9 | urlFilter,
10 | } from '@/pages/Requests/hooks/filters'
11 | import { useProfile } from '@/store'
12 | import { RecentRequests } from '@/types'
13 | import fetcher from '@/utils/fetcher'
14 |
15 | import { FilterSchema } from '../components/FilterPopover'
16 |
17 | import { useRequestListReducer, RequestListActions } from './reducer'
18 |
19 | type Props = {
20 | isAutoRefreshEnabled?: boolean
21 | sourceIp?: string | null
22 | onlyActive?: boolean
23 | filter: FilterSchema
24 | sortRule: SorterRules
25 | }
26 |
27 | const useRequestsList = ({
28 | isAutoRefreshEnabled,
29 | sourceIp,
30 | onlyActive,
31 | filter,
32 | sortRule,
33 | }: Props) => {
34 | const profile = useProfile()
35 |
36 | const { data: recentRequests } = useSWR(
37 | () =>
38 | profile !== undefined
39 | ? `/requests/${onlyActive ? 'active' : 'recent'}`
40 | : undefined,
41 | fetcher,
42 | {
43 | revalidateOnFocus: false,
44 | revalidateOnReconnect: false,
45 | dedupingInterval: 1000,
46 | refreshInterval: isAutoRefreshEnabled
47 | ? profile?.platform === 'macos'
48 | ? 2000
49 | : 4000
50 | : 0,
51 | },
52 | )
53 |
54 | const [{ requestList }, dispatch] = useRequestListReducer()
55 | const filteredRequestList = useMemo(() => {
56 | if (!requestList) {
57 | return undefined
58 | }
59 |
60 | return requestList
61 | .filter((item) => {
62 | return (
63 | activeFilter(onlyActive, item) &&
64 | sourceIpFilter(sourceIp, item) &&
65 | urlFilter(filter.urlFilter, item)
66 | )
67 | })
68 | .sort((a, b) => sorter(sortRule, a, b))
69 | }, [filter.urlFilter, onlyActive, requestList, sortRule, sourceIp])
70 |
71 | useEffect(() => {
72 | if (!recentRequests) {
73 | return
74 | }
75 |
76 | dispatch({
77 | type: RequestListActions.LOAD_REQUESTS,
78 | payload: recentRequests.requests,
79 | })
80 | }, [recentRequests, dispatch])
81 |
82 | useEffect(() => {
83 | if (!recentRequests) {
84 | return
85 | }
86 |
87 | dispatch({
88 | type: RequestListActions.LOAD_REQUESTS,
89 | payload: recentRequests.requests,
90 | })
91 | }, [recentRequests, dispatch])
92 |
93 | return {
94 | requestList: filteredRequestList,
95 | }
96 | }
97 |
98 | export default useRequestsList
99 |
--------------------------------------------------------------------------------
/src/pages/Scripting/Evaluate/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { lazy, Suspense, useState } from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import { LifeBuoy } from 'lucide-react'
4 |
5 | import CodeMirrorLoading from '@/components/CodeMirrorLoading'
6 | import FixedFullscreenContainer from '@/components/FixedFullscreenContainer'
7 | import PageTitle from '@/components/PageTitle'
8 | import {
9 | withScriptExecutionProvider,
10 | useExecuteScript,
11 | } from '@/components/ScriptExecutionProvider'
12 | import { Button } from '@/components/ui/button'
13 | import { Input } from '@/components/ui/input'
14 | import { Label } from '@/components/ui/label'
15 |
16 | const CodeMirror = lazy(() => import('@/components/CodeMirror'))
17 |
18 | export const Component: React.FC = withScriptExecutionProvider(
19 | function EvaluatePage() {
20 | const { t } = useTranslation()
21 |
22 | const [code, setCode] = useState(() =>
23 | t('scripting.editor_placeholder'),
24 | )
25 |
26 | const { execute, execution } = useExecuteScript()
27 | const [timeout, setTimeoutValue] = useState(5)
28 |
29 | return (
30 |
31 |
32 |
33 |
34 |
35 | }>
36 | {
40 | setCode(value)
41 | }}
42 | />
43 |
44 |
45 |
46 |
47 |
execute(code, { timeout })}
49 | isLoading={execution?.isLoading}
50 | loadingLabel={t('scripting.running')}
51 | >
52 | {t('scripting.run_script_button_title')}
53 |
54 |
55 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | {t('scripting.timeout')}
67 |
73 | setTimeoutValue(Number((target as HTMLInputElement).value))
74 | }
75 | />
76 |
77 |
78 |
79 |
80 | )
81 | },
82 | )
83 |
84 | export { ErrorBoundary } from '@/components/ErrorBoundary'
85 |
--------------------------------------------------------------------------------
/src/pages/Traffic/components/TrafficDataRow.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo, useState } from 'react'
2 | import { Collapse } from 'react-collapse'
3 | import { useTranslation } from 'react-i18next'
4 | import { css } from '@emotion/react'
5 | import bytes from 'bytes'
6 | import { ChevronRight } from 'lucide-react'
7 | import tw from 'twin.macro'
8 |
9 | import { DataRow, DataRowMain, DataRowSub } from '@/components/Data'
10 | import { ConnectorTraffic } from '@/types'
11 |
12 | interface TrafficDataRowProps {
13 | name: string
14 | data: ConnectorTraffic
15 | }
16 |
17 | const TrafficDataRow: React.FC = ({ name, data }) => {
18 | const { t } = useTranslation()
19 | const [isDetailsOpen, setIsDetailsOpen] = useState(false)
20 | const tcpStat = useMemo(() => {
21 | if (!data.statistics || !data.statistics.length) return
22 |
23 | let total = 0
24 |
25 | data.statistics.forEach((stat) => {
26 | total += stat.srtt
27 | })
28 |
29 | return Math.round(total / data.statistics.length)
30 | }, [data.statistics])
31 |
32 | return (
33 | setIsDetailsOpen(!isDetailsOpen)}
42 | >
43 |
44 | {name}
45 |
46 |
47 | {t('traffic.total')} {bytes(data.in + data.out)}
48 |
49 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | {t('traffic.traffic')}
62 |
63 | {`${t('traffic.upload')}: ${bytes(data.out)}`}
64 |
65 | {`${t('traffic.download')}: ${bytes(data.in)}`}
66 |
67 |
68 |
69 | {t('traffic.current_speed')}
70 |
71 | {`${t('traffic.upload')}: ${bytes(
72 | data.outCurrentSpeed,
73 | )}/s`}
74 |
75 | {`${t('traffic.download')}: ${bytes(
76 | data.inCurrentSpeed,
77 | )}/s`}
78 |
79 |
80 |
81 | {t('traffic.maximum_speed')}
82 |
83 | {`${t('traffic.upload')}: ${bytes(
84 | data.outMaxSpeed,
85 | )}/s`}
86 |
87 | {`${t('traffic.download')}: ${bytes(
88 | data.inMaxSpeed,
89 | )}/s`}
90 |
91 |
92 | {!!tcpStat && (
93 |
94 | {t('traffic.tcp_summary')}
95 | {`${t(
96 | 'traffic.avg_rtt',
97 | )} ${tcpStat}ms`}
98 |
99 | )}
100 |
101 |
102 |
103 | )
104 | }
105 |
106 | export default TrafficDataRow
107 |
--------------------------------------------------------------------------------
/src/pages/Traffic/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import dayjs from 'dayjs'
4 | import relativeTime from 'dayjs/plugin/relativeTime'
5 | import tw from 'twin.macro'
6 |
7 | import { DataGroup, DataRow, DataRowMain } from '@/components/Data'
8 | import HorizontalSafeArea from '@/components/HorizontalSafeArea'
9 | import PageTitle from '@/components/PageTitle'
10 | import { useConnectors, useInterfaces, useStartTime } from '@/store'
11 | import { ConnectorTraffic, Traffic } from '@/types'
12 |
13 | import TrafficDataRow from './components/TrafficDataRow'
14 |
15 | dayjs.extend(relativeTime)
16 |
17 | const TrafficWrapper = tw.div`p-4 md:p-5 space-y-4 md:space-y-5`
18 |
19 | export const Component: React.FC = () => {
20 | const { t } = useTranslation()
21 | const connectors = useConnectors()
22 | const interfaces = useInterfaces()
23 | const startTime = useStartTime()
24 |
25 | const getSortedTraffic = (
26 | connector: Traffic['connector'],
27 | ): Array => {
28 | const result: Array = []
29 |
30 | Object.keys(connector).forEach((name) => {
31 | result.push({
32 | name,
33 | ...connector[name],
34 | })
35 | })
36 |
37 | return result.sort((a, b) => {
38 | return b.in + b.out - (a.in + a.out)
39 | })
40 | }
41 |
42 | return (
43 | <>
44 |
45 |
46 |
47 | {startTime && (
48 |
49 |
50 |
51 |
52 | {t('traffic.start_time')}
53 | {dayjs(startTime).format('LLL')}
54 |
55 |
56 |
57 |
58 | {t('traffic.uptime')}
59 |
60 | {dayjs(startTime).toNow(true)}
61 |
62 |
63 |
64 |
65 |
66 |
67 | {Object.keys(interfaces).map((name) => {
68 | const data = interfaces[name]
69 | return
70 | })}
71 |
72 |
73 |
74 | {getSortedTraffic(connectors).map((data) => {
75 | const name = data.name
76 | return
77 | })}
78 |
79 |
80 | )}
81 |
82 | >
83 | )
84 | }
85 |
86 | Component.displayName = 'TrafficPage'
87 |
88 | export { ErrorBoundary } from '@/components/ErrorBoundary'
89 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import 'twin.macro'
4 | import { css as cssImport } from '@emotion/react'
5 | import { CSSInterpolation } from '@emotion/serialize'
6 | import styledImport from '@emotion/styled'
7 |
8 | declare module 'twin.macro' {
9 | // The styled and css imports
10 | const styled: typeof styledImport
11 | const css: typeof cssImport
12 | }
13 |
14 | declare module 'react' {
15 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
16 | interface DOMAttributes {
17 | tw?: string
18 | css?: CSSInterpolation
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/router/context.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from 'react'
2 | import { Location } from 'react-router-dom'
3 |
4 | import type { Route } from './types'
5 |
6 | export type RouterContext = {
7 | routes: Route[]
8 | currentLocation: Location | null
9 | } | null
10 |
11 | export const RouterContext = createContext(null)
12 |
--------------------------------------------------------------------------------
/src/router/hooks.ts:
--------------------------------------------------------------------------------
1 | import { useContext, useMemo } from 'react'
2 | import { useLocation } from 'react-router-dom'
3 |
4 | import { RouterContext } from './context'
5 |
6 | export const useRoutesConfig = () => {
7 | const context = useContext(RouterContext)
8 |
9 | if (!context?.routes) {
10 | throw new Error('useRoutes must be used within a RouterProvider')
11 | }
12 |
13 | return context.routes
14 | }
15 |
16 | export const useRouteOptions = () => {
17 | const location = useLocation()
18 | const routes = useRoutesConfig()
19 |
20 | const currentRoute = useMemo(() => {
21 | return routes.find((route) => location.pathname === route.path)
22 | }, [location.pathname, routes])
23 |
24 | return currentRoute?.routeOptions
25 | }
26 |
--------------------------------------------------------------------------------
/src/router/index.ts:
--------------------------------------------------------------------------------
1 | export { RouterProvider } from './router'
2 | export * from './hooks'
3 | export * from './types'
4 |
--------------------------------------------------------------------------------
/src/router/router.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react'
2 | import {
3 | createBrowserRouter,
4 | createHashRouter,
5 | RouterProvider as RouterProviderBase,
6 | } from 'react-router-dom'
7 |
8 | import App from '@/App'
9 | import AppContainer from '@/AppContainer'
10 | import { ErrorBoundary } from '@/components/ErrorBoundary'
11 |
12 | import { RouterContext } from './context'
13 |
14 | import type { Route } from './types'
15 |
16 | const createRouter = (routes: Route[]) => {
17 | return process.env.REACT_APP_HASH_ROUTER === 'true'
18 | ? createHashRouter(routes)
19 | : createBrowserRouter(routes)
20 | }
21 |
22 | export type RouterProviderProps = {
23 | value: {
24 | routes: Route[]
25 | }
26 | }
27 |
28 | export const RouterProvider = ({ value }: RouterProviderProps) => {
29 | const routerRef = useRef(
30 | createRouter([
31 | {
32 | path: '/',
33 | element: (
34 |
35 |
36 |
37 | ),
38 | children: value.routes,
39 | errorElement: ,
40 | },
41 | ]),
42 | )
43 |
44 | return (
45 |
51 |
52 |
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/src/router/types.ts:
--------------------------------------------------------------------------------
1 | import type { RouteObject } from 'react-router'
2 |
3 | export type RoutesConfig = {
4 | routes: Route[]
5 | }
6 |
7 | export type Route = RouteObject & {
8 | title?: () => string
9 | routeOptions?: {
10 | fullscreen?: boolean
11 | bottomSafeArea?: boolean
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/routes.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Navigate } from 'react-router-dom'
3 |
4 | import { Component as HomePage } from '@/pages/Home'
5 | import { Component as LandingPage } from '@/pages/Landing'
6 | import { RoutesConfig } from '@/router'
7 |
8 | const routes: RoutesConfig = {
9 | routes: [
10 | {
11 | path: '/',
12 | element: ,
13 | },
14 | {
15 | path: '/home',
16 | element: ,
17 | },
18 | {
19 | path: '/policies',
20 | lazy: () => import('@/pages/Policies'),
21 | },
22 | {
23 | path: '/requests',
24 | lazy: () => import('@/pages/Requests'),
25 | routeOptions: {
26 | fullscreen: true,
27 | bottomSafeArea: false,
28 | },
29 | },
30 | {
31 | path: '/traffic',
32 | lazy: () => import('@/pages/Traffic'),
33 | },
34 | {
35 | path: '/modules',
36 | lazy: () => import('@/pages/Modules'),
37 | },
38 | {
39 | path: '/scripting',
40 | lazy: () => import('@/pages/Scripting'),
41 | routeOptions: {
42 | fullscreen: true,
43 | },
44 | },
45 | {
46 | path: '/scripting/evaluate',
47 | lazy: () => import('@/pages/Scripting/Evaluate'),
48 | routeOptions: {
49 | fullscreen: true,
50 | },
51 | },
52 | {
53 | path: '/dns',
54 | lazy: () => import('@/pages/Dns'),
55 | routeOptions: {
56 | fullscreen: true,
57 | },
58 | },
59 | {
60 | path: '/devices',
61 | lazy: () => import('@/pages/Devices'),
62 | },
63 | {
64 | path: '/profiles',
65 | lazy: () => import('@/pages/Profiles/Manage'),
66 | },
67 | {
68 | path: '/profiles/current',
69 | lazy: () => import('@/pages/Profiles/Current'),
70 | routeOptions: {
71 | fullscreen: true,
72 | bottomSafeArea: false,
73 | },
74 | },
75 | {
76 | path: '*',
77 | element: ,
78 | },
79 | ],
80 | }
81 |
82 | export default routes
83 |
--------------------------------------------------------------------------------
/src/service-worker.ts:
--------------------------------------------------------------------------------
1 | ///
2 | /* eslint-disable no-restricted-globals */
3 |
4 | // This service worker can be customized!
5 | // See https://developers.google.com/web/tools/workbox/modules
6 | // for the list of available Workbox modules, or add any other
7 | // code you'd like.
8 | // You can also remove this file if you'd prefer not to use a
9 | // service worker, and the Workbox build step will be skipped.
10 |
11 | import { clientsClaim } from 'workbox-core'
12 | import { ExpirationPlugin } from 'workbox-expiration'
13 | import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'
14 | import { registerRoute } from 'workbox-routing'
15 | import { StaleWhileRevalidate } from 'workbox-strategies'
16 |
17 | declare const self: ServiceWorkerGlobalScope
18 |
19 | clientsClaim()
20 |
21 | // Precache all of the assets generated by your build process.
22 | // Their URLs are injected into the manifest variable below.
23 | // This variable must be present somewhere in your service worker file,
24 | // even if you decide not to use precaching. See https://cra.link/PWA
25 | precacheAndRoute(self.__WB_MANIFEST)
26 |
27 | // Set up App Shell-style routing, so that all navigation requests
28 | // are fulfilled with your index.html shell. Learn more at
29 | // https://developers.google.com/web/fundamentals/architecture/app-shell
30 | const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$')
31 | registerRoute(
32 | // Return false to exempt requests from being fulfilled by index.html.
33 | ({ request, url }: { request: Request; url: URL }) => {
34 | // If this isn't a navigation, skip.
35 | if (request.mode !== 'navigate') {
36 | return false
37 | }
38 |
39 | // If this is a URL that starts with /_, skip.
40 | if (url.pathname.startsWith('/_')) {
41 | return false
42 | }
43 |
44 | // If this looks like a URL for a resource, because it contains
45 | // a file extension, skip.
46 | if (url.pathname.match(fileExtensionRegexp)) {
47 | return false
48 | }
49 |
50 | // Return true to signal that we want to use the handler.
51 | return true
52 | },
53 | createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html'),
54 | )
55 |
56 | // An example runtime caching route for requests that aren't handled by the
57 | // precache, in this case same-origin .png requests like those from in public/
58 | registerRoute(
59 | // Add in any other file extensions or routing criteria as needed.
60 | ({ url }) =>
61 | url.origin === self.location.origin && url.pathname.endsWith('.png'),
62 | // Customize this strategy as needed, e.g., by changing to CacheFirst.
63 | new StaleWhileRevalidate({
64 | cacheName: 'images',
65 | plugins: [
66 | // Ensure that once this runtime cache reaches a maximum size the
67 | // least-recently used images are removed.
68 | new ExpirationPlugin({ maxEntries: 50 }),
69 | ],
70 | }),
71 | )
72 |
73 | // This allows the web app to trigger skipWaiting via
74 | // registration.waiting.postMessage({type: 'SKIP_WAITING'})
75 | self.addEventListener('message', (event) => {
76 | if (event.data && event.data.type === 'SKIP_WAITING') {
77 | self.skipWaiting()
78 | }
79 | })
80 |
81 | // Any other custom service worker logic can go here.
82 |
--------------------------------------------------------------------------------
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom'
2 |
--------------------------------------------------------------------------------
/src/store/hooks.ts:
--------------------------------------------------------------------------------
1 | import { useDispatch, useSelector } from 'react-redux'
2 | import type { TypedUseSelectorHook } from 'react-redux'
3 |
4 | import { selectHistory } from '@/store/slices/history'
5 | import {
6 | selectPLatform,
7 | selectPlatformBuild,
8 | selectPlatformVersion,
9 | selectProfile,
10 | } from '@/store/slices/profile'
11 | import {
12 | selectConnectors,
13 | selectInterfaces,
14 | selectStartTime,
15 | selectHistory as selectTrafficHistory,
16 | } from '@/store/slices/traffic'
17 |
18 | import type { RootState, AppDispatch } from './types'
19 |
20 | // Use throughout your app instead of plain `useDispatch` and `useSelector`
21 | export const useAppDispatch: () => AppDispatch = useDispatch
22 | export const useAppSelector: TypedUseSelectorHook = useSelector
23 |
24 | /**
25 | * History slice hooks
26 | */
27 | export const useHistory = () => useAppSelector(selectHistory)
28 |
29 | /**
30 | * Profile slice hooks
31 | */
32 | export const useProfile = () => useAppSelector(selectProfile)
33 | export const usePlatform = () => useAppSelector(selectPLatform)
34 | export const usePlatformVersion = () => useAppSelector(selectPlatformVersion)
35 | export const usePlatformBuild = () => useAppSelector(selectPlatformBuild)
36 | export const useSurgeHost = () => {
37 | const profile = useProfile()
38 |
39 | if (!profile) return null
40 |
41 | const { tls, host, port } = profile
42 |
43 | return `${tls ? 'https:' : 'http:'}//${host}:${port}`
44 | }
45 |
46 | /**
47 | * Traffic slice hooks
48 | */
49 | export const useInterfaces = () => useAppSelector(selectInterfaces)
50 | export const useConnectors = () => useAppSelector(selectConnectors)
51 | export const useTrafficHistory = () => useAppSelector(selectTrafficHistory)
52 | export const useStartTime = () => useAppSelector(selectStartTime)
53 |
--------------------------------------------------------------------------------
/src/store/index.ts:
--------------------------------------------------------------------------------
1 | export * from './store'
2 | export * from './types'
3 | export * from './hooks'
4 |
--------------------------------------------------------------------------------
/src/store/slices/history/index.ts:
--------------------------------------------------------------------------------
1 | export * from './slice'
2 | export * from './selectors'
3 |
--------------------------------------------------------------------------------
/src/store/slices/history/selectors.ts:
--------------------------------------------------------------------------------
1 | import { RootState } from '@/store'
2 |
3 | export const selectHistory = (state: RootState) => state.history.history
4 |
--------------------------------------------------------------------------------
/src/store/slices/history/slice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit'
2 | import store from 'store2'
3 |
4 | import {
5 | addHistory,
6 | loadHistoryFromLocalStorage,
7 | } from '@/store/slices/history/thunks'
8 | import { Profile } from '@/types'
9 | import { ExistingProfiles, LastUsedProfile } from '@/utils/constant'
10 |
11 | import type { PayloadAction } from '@reduxjs/toolkit'
12 |
13 | export interface HistoryState {
14 | history: Profile[] | undefined
15 | }
16 |
17 | const initialState: HistoryState = {
18 | history: undefined,
19 | }
20 |
21 | const historySlice = createSlice({
22 | name: 'history',
23 | initialState,
24 | reducers: {
25 | deleteHistory: (
26 | state,
27 | action: PayloadAction<{
28 | id: string
29 | }>,
30 | ) => {
31 | if (!state.history) return
32 |
33 | const { id } = action.payload
34 | const history = state.history.filter((profile) => profile.id !== id)
35 |
36 | store.set(ExistingProfiles, history)
37 |
38 | state.history = history
39 | },
40 | deleteAllHistory: (state) => {
41 | store.remove(LastUsedProfile)
42 | store.remove(ExistingProfiles)
43 |
44 | state.history = []
45 | },
46 | },
47 | extraReducers: (builder) => {
48 | builder.addCase(loadHistoryFromLocalStorage.fulfilled, (state, action) => {
49 | state.history = action.payload
50 | })
51 |
52 | builder.addCase(addHistory.fulfilled, (state, action) => {
53 | if (!action.payload) return
54 |
55 | if (!state.history) {
56 | state.history = [action.payload]
57 | } else {
58 | state.history = [...state.history, action.payload]
59 | }
60 | })
61 | },
62 | })
63 | const historyActions = {
64 | ...historySlice.actions,
65 | addHistory,
66 | loadHistoryFromLocalStorage,
67 | } as const
68 |
69 | export { historySlice, historyActions }
70 |
--------------------------------------------------------------------------------
/src/store/slices/history/thunks.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit'
2 | import { find } from 'lodash-es'
3 | import store from 'store2'
4 |
5 | import type { RootState } from '@/store'
6 | import { profileActions } from '@/store/slices/profile'
7 | import type { Profile } from '@/types'
8 | import { ExistingProfiles, LastUsedProfile } from '@/utils/constant'
9 |
10 | export const loadHistoryFromLocalStorage = createAsyncThunk<
11 | Profile[],
12 | {
13 | loadLastUsedProfile?: boolean
14 | },
15 | {
16 | state: RootState
17 | }
18 | >(
19 | 'history/loadHistoryFromLocalStorage',
20 | async ({ loadLastUsedProfile }, thunkAPI) => {
21 | const storedExistingProfiles: Profile[] | null = store.get(ExistingProfiles)
22 | const lastUsedProfileId = store.get(LastUsedProfile)
23 | const lastUsedProfile = find(storedExistingProfiles, {
24 | id: lastUsedProfileId,
25 | })
26 |
27 | if (loadLastUsedProfile && lastUsedProfile) {
28 | thunkAPI.dispatch(profileActions.update(lastUsedProfile))
29 | }
30 |
31 | return storedExistingProfiles || []
32 | },
33 | )
34 |
35 | export const addHistory = createAsyncThunk<
36 | Profile | undefined,
37 | {
38 | profile: Profile
39 | remember?: boolean
40 | },
41 | {
42 | state: RootState
43 | }
44 | >('history/addHistory', async ({ profile, remember }, thunkAPI) => {
45 | const state = thunkAPI.getState().history
46 | const history = state.history ? [...state.history, profile] : [profile]
47 |
48 | if (remember) {
49 | store.set(ExistingProfiles, history)
50 | store.set(LastUsedProfile, profile.id)
51 | }
52 |
53 | return remember ? profile : undefined
54 | })
55 |
--------------------------------------------------------------------------------
/src/store/slices/profile/index.ts:
--------------------------------------------------------------------------------
1 | export * from './slice'
2 | export * from './selectors'
3 |
--------------------------------------------------------------------------------
/src/store/slices/profile/selectors.ts:
--------------------------------------------------------------------------------
1 | import { RootState } from '@/store'
2 |
3 | export const selectProfile = (state: RootState) => state.profile.profile
4 |
5 | export const selectPLatform = (state: RootState) =>
6 | state.profile.profile?.platform
7 |
8 | export const selectPlatformVersion = (state: RootState) =>
9 | state.profile.profile?.platformVersion
10 |
11 | export const selectPlatformBuild = (state: RootState) =>
12 | state.profile.profile?.platformBuild
13 |
--------------------------------------------------------------------------------
/src/store/slices/profile/slice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'
2 |
3 | import { Profile } from '@/types'
4 | import { setServer } from '@/utils/fetcher'
5 | import { updateStoredProfile } from '@/utils/store'
6 |
7 | export interface ProfileState {
8 | profile: Profile | undefined
9 | }
10 |
11 | const initialState: ProfileState = {
12 | profile: undefined,
13 | }
14 |
15 | const profileSlice = createSlice({
16 | name: 'profile',
17 | initialState,
18 | reducers: {
19 | update: (state, action: PayloadAction) => {
20 | setServer(action.payload.host, action.payload.port, action.payload.key, {
21 | tls: action.payload.tls,
22 | })
23 |
24 | state.profile = action.payload
25 | },
26 | clear: (state) => {
27 | state.profile = undefined
28 | },
29 | updatePlatformVersion: (
30 | state,
31 | action: PayloadAction<{
32 | platformVersion: Profile['platformVersion']
33 | }>,
34 | ) => {
35 | if (!state.profile) {
36 | throw new Error(
37 | 'updatePlatformVersion cannot be dispatched if the profile is absent.',
38 | )
39 | }
40 |
41 | const profile = state.profile
42 | const updated = {
43 | ...profile,
44 | platformVersion: action.payload.platformVersion,
45 | }
46 |
47 | updateStoredProfile(updated.id, updated)
48 | state.profile = updated
49 | },
50 | },
51 | })
52 |
53 | const profileActions = profileSlice.actions
54 |
55 | export { profileSlice, profileActions }
56 |
--------------------------------------------------------------------------------
/src/store/slices/profile/thunks.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geekdada/yasd/e50734733fd5c2534c322d31d5e15e9bb79a7efc/src/store/slices/profile/thunks.ts
--------------------------------------------------------------------------------
/src/store/slices/traffic/index.ts:
--------------------------------------------------------------------------------
1 | export * from './slice'
2 | export * from './selectors'
3 |
--------------------------------------------------------------------------------
/src/store/slices/traffic/selectors.ts:
--------------------------------------------------------------------------------
1 | import { RootState } from '@/store'
2 |
3 | export const selectInterfaces = (state: RootState) => state.traffic.interface
4 |
5 | export const selectConnectors = (state: RootState) => state.traffic.connector
6 |
7 | export const selectHistory = (state: RootState) => state.traffic.history
8 |
9 | export const selectStartTime = (state: RootState) => state.traffic.startTime
10 |
--------------------------------------------------------------------------------
/src/store/slices/traffic/slice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit'
2 | import dayjs from 'dayjs'
3 |
4 | import type { ConnectorTraffic, DataPoint, Traffic } from '@/types'
5 |
6 | import type { PayloadAction } from '@reduxjs/toolkit'
7 |
8 | const HISTORY_SIZE = 60
9 |
10 | export interface TrafficState {
11 | startTime?: number
12 | interface: {
13 | [name: string]: ConnectorTraffic
14 | }
15 | connector: {
16 | [name: string]: ConnectorTraffic
17 | }
18 | history: {
19 | down: DataPoint[]
20 | up: DataPoint[]
21 | }
22 | }
23 |
24 | const initialState: TrafficState = getInitialState()
25 |
26 | const trafficSlice = createSlice({
27 | name: 'traffic',
28 | initialState,
29 | reducers: {
30 | updateStartTime(state, action: PayloadAction) {
31 | state.startTime = action.payload
32 | },
33 | updateInterface(state, action: PayloadAction) {
34 | state.interface = action.payload
35 | },
36 | updateConnector(state, action: PayloadAction) {
37 | state.connector = action.payload
38 | },
39 | updateHistory(
40 | state,
41 | action: PayloadAction<{
42 | down?: DataPoint
43 | up?: DataPoint
44 | }>,
45 | ) {
46 | const history = {
47 | down: [...state.history.down],
48 | up: [...state.history.up],
49 | }
50 |
51 | if (action.payload.down) {
52 | history.down.unshift(action.payload.down)
53 | }
54 | if (action.payload.up) {
55 | history.up.unshift(action.payload.up)
56 | }
57 |
58 | if (history.down.length > HISTORY_SIZE) {
59 | history.down.pop()
60 | }
61 | if (history.up.length > HISTORY_SIZE) {
62 | history.up.pop()
63 | }
64 |
65 | state.history = history
66 | },
67 | clear(state) {
68 | Object.assign(state, getInitialState())
69 | },
70 | },
71 | })
72 |
73 | function getInitialState(): TrafficState {
74 | return {
75 | startTime: undefined,
76 | interface: {},
77 | connector: {},
78 | history: {
79 | down: getInitialTrafficHistory(),
80 | up: getInitialTrafficHistory(),
81 | },
82 | }
83 | }
84 |
85 | function getInitialTrafficHistory(): DataPoint[] {
86 | const result = []
87 |
88 | for (let i = 1; i < HISTORY_SIZE + 1; i++) {
89 | const time = dayjs()
90 | .subtract(i * 1000, 'millisecond')
91 | .toDate()
92 | .getTime()
93 |
94 | result.push({ x: time, y: 0 })
95 | }
96 |
97 | return result
98 | }
99 |
100 | const trafficActions = trafficSlice.actions
101 |
102 | export { trafficSlice, trafficActions }
103 |
--------------------------------------------------------------------------------
/src/store/slices/traffic/thunks.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geekdada/yasd/e50734733fd5c2534c322d31d5e15e9bb79a7efc/src/store/slices/traffic/thunks.ts
--------------------------------------------------------------------------------
/src/store/store.ts:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit'
2 |
3 | import { historySlice } from './slices/history'
4 | import { profileSlice } from './slices/profile'
5 | import { trafficSlice } from './slices/traffic'
6 |
7 | export const store = configureStore({
8 | reducer: {
9 | history: historySlice.reducer,
10 | profile: profileSlice.reducer,
11 | traffic: trafficSlice.reducer,
12 | },
13 | })
14 |
--------------------------------------------------------------------------------
/src/store/types.ts:
--------------------------------------------------------------------------------
1 | import { store } from './store'
2 |
3 | // Infer the `RootState` and `AppDispatch` types from the store itself
4 | export type RootState = ReturnType
5 |
6 | // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
7 | export type AppDispatch = typeof store.dispatch
8 |
--------------------------------------------------------------------------------
/src/styles/global.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | @apply h-full;
3 | }
4 |
5 | body {
6 | @apply antialiased;
7 | }
8 |
9 | #root {
10 | background-color: hsl(210, 20%, 96%);
11 | @apply min-h-full flex flex-col;
12 | }
13 |
14 | .dark #root {
15 | background-color: hsl(0 0% 4%);
16 | }
17 |
--------------------------------------------------------------------------------
/src/styles/shadcn.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0, 0%, 100%;
8 | --foreground: 214, 10%, 14%;
9 |
10 | --muted: 210, 20%, 98%;
11 | --muted-foreground: 0 0% 45.1%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 0 0% 3.9%;
15 |
16 | --card: 0 0% 100%;
17 | --card-foreground: 0 0% 3.9%;
18 |
19 | --border: 0 0% 89.8%;
20 | --input: 0 0% 89.8%;
21 |
22 | --primary: 0 0% 9%;
23 | --primary-foreground: 0 0% 98%;
24 |
25 | --secondary: 0 0% 96.1%;
26 | --secondary-foreground: 0 0% 9%;
27 |
28 | --accent: 0 0% 96.1%;
29 | --accent-foreground: 0 0% 9%;
30 |
31 | --destructive: 0 84.2% 60.2%;
32 | --destructive-foreground: 0 0% 98%;
33 |
34 | --switch: 211, 100%, 54%;
35 |
36 | --ring: 0 0% 63.9%;
37 |
38 | --radius: 0.5rem;
39 | }
40 |
41 | .dark {
42 | --background: 0 0% 3.9%;
43 | --foreground: 0 0% 98%;
44 |
45 | --muted: 0 0% 14.9%;
46 | --muted-foreground: 0 0% 63.9%;
47 |
48 | --popover: 0 0% 3.9%;
49 | --popover-foreground: 0 0% 98%;
50 |
51 | --card: 0 0% 3.9%;
52 | --card-foreground: 0 0% 98%;
53 |
54 | --border: 0 0% 14.9%;
55 | --input: 0 0% 14.9%;
56 |
57 | --primary: 0 0% 98%;
58 | --primary-foreground: 0 0% 9%;
59 |
60 | --secondary: 0 0% 14.9%;
61 | --secondary-foreground: 0 0% 98%;
62 |
63 | --accent: 0 0% 14.9%;
64 | --accent-foreground: 0 0% 98%;
65 |
66 | --destructive: 0 62.8% 30.6%;
67 | --destructive-foreground: 0 85.7% 97.3%;
68 |
69 | --ring: 0 0% 14.9%;
70 | }
71 | }
72 |
73 | @layer base {
74 | * {
75 | @apply border-border;
76 | }
77 | body {
78 | @apply bg-background text-foreground;
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/types/ui.ts:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export type ClickEvent =
4 | | React.MouseEvent
5 | | React.KeyboardEvent
6 |
7 | export type OnClose = (event?: ClickEvent) => void
8 |
--------------------------------------------------------------------------------
/src/utils/constant.ts:
--------------------------------------------------------------------------------
1 | export const ExistingProfiles = 'existingProfiles'
2 | export const LastUsedProfile = 'lastUsedProfile'
3 | export const LastUsedVersion = 'lastUsedVersion'
4 | export const LastUsedLanguage = 'lastUsedLanguage'
5 | export const LastUsedScriptArgument = 'lastUsedScriptArgument'
6 |
--------------------------------------------------------------------------------
/src/utils/fetcher.ts:
--------------------------------------------------------------------------------
1 | import { toast } from 'react-hot-toast'
2 | import axios, { AxiosRequestConfig } from 'axios'
3 |
4 | const client = axios.create({
5 | baseURL: '/v1',
6 | })
7 |
8 | export function setServer(
9 | host: string,
10 | port: number,
11 | key: string,
12 | options?: {
13 | tls?: boolean
14 | },
15 | ): void {
16 | const useTls = options?.tls === true
17 |
18 | client.defaults.headers['x-key'] = key
19 | client.defaults.timeout = 5000
20 |
21 | client.defaults.baseURL = `${useTls ? 'https:' : 'http:'}//${host}:${port}/v1`
22 | }
23 |
24 | const fetcher = async (requestConfig: AxiosRequestConfig): Promise => {
25 | return client
26 | .request(requestConfig)
27 | .then((res) => res.data)
28 | .catch((error) => {
29 | if (error.response) {
30 | // The request was made and the server responded with a status code
31 | // that falls out of the range of 2xx
32 | console.error(error.response.data)
33 | console.error(error.response.status)
34 | toast.error('请求错误: ' + error.message + `(${error.response.status})`)
35 | } else if (error.request) {
36 | // The request was made but no response was received
37 | // `error.request` is an instance of XMLHttpRequest in the browser and an instance of
38 | // http.ClientRequest in node.js
39 | console.error(error.request)
40 | toast.error('无法连接服务器: ' + error.message, {
41 | id: error.message,
42 | })
43 | } else {
44 | // Something happened in setting up the request that triggered an Error
45 | console.error('Error', error.message)
46 | toast.error('发生错误: ' + error.message)
47 | }
48 |
49 | throw error
50 | })
51 | }
52 |
53 | export default fetcher
54 | export { client as httpClient }
55 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | import { unregisterAsync } from '@/serviceWorkerRegistration'
2 |
3 | export const isFalsy = (obj: string | boolean | 1 | 0) =>
4 | obj === 0 || obj === false || (typeof obj === 'string' && obj.length === 0)
5 |
6 | export const isTruthy = (obj: string | boolean | 1 | 0) =>
7 | obj === 1 || obj === true || (typeof obj === 'string' && obj.length > 0)
8 |
9 | export const isRunInSurge = (): boolean =>
10 | process.env.REACT_APP_RUN_IN_SURGE === 'true'
11 |
12 | export const forceRefresh = async (): Promise => {
13 | if (process.env.REACT_APP_USE_SW === 'true') {
14 | await unregisterAsync()
15 | }
16 |
17 | window.location.reload()
18 | }
19 |
20 | /**
21 | * The following IP formats can be handled:
22 | * 1.1.1.1(Proxy),
23 | * 1.1.1.1 (Proxy),
24 | * 2001:0db8:85a3:0000:0000:8a2e:0370:7334(Proxy),
25 | * 2001:0db8:85a3:0000:0000:8a2e:0370:7334 (Proxy),
26 | */
27 | export const onlyIP = (ip: string) => {
28 | const ipAddressRegex =
29 | /(?:\d{1,3}\.){3}\d{1,3}|(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}/g
30 | const matchArray = ip.match(ipAddressRegex)
31 | return matchArray?.length ? matchArray[0] : ip
32 | }
33 |
--------------------------------------------------------------------------------
/src/utils/profiling.ts:
--------------------------------------------------------------------------------
1 | import { scan } from 'react-scan'
2 |
3 | if (process.env.REACT_APP_PROFILE === 'true') {
4 | scan({
5 | enabled: true,
6 | log: false, // logs render info to console (default: false)
7 | })
8 | }
9 |
--------------------------------------------------------------------------------
/src/utils/shadcn.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/src/utils/store.ts:
--------------------------------------------------------------------------------
1 | import { findIndex } from 'lodash-es'
2 | import store from 'store2'
3 |
4 | import type { Profile } from '@/types'
5 |
6 | import { ExistingProfiles, LastUsedProfile } from './constant'
7 |
8 | export const updateStoredProfile = (
9 | profileId: Profile['id'],
10 | newProfile: Profile,
11 | ): void => {
12 | const storedExistingProfiles: Profile[] = store.get(ExistingProfiles)
13 |
14 | if (storedExistingProfiles) {
15 | const result = findIndex(storedExistingProfiles, { id: profileId })
16 |
17 | if (result !== -1) {
18 | storedExistingProfiles.splice(result, 1, newProfile)
19 | store.set(ExistingProfiles, storedExistingProfiles)
20 | }
21 | }
22 | }
23 |
24 | export const rememberLastUsed = (id: string) => {
25 | store.set(LastUsedProfile, id)
26 | }
27 |
--------------------------------------------------------------------------------
/src/utils/validation.ts:
--------------------------------------------------------------------------------
1 | import { FieldError, RegisterOptions } from 'react-hook-form'
2 |
3 | export function getValidationHint(
4 | typeMap: {
5 | [key in keyof RegisterOptions]?: string
6 | } & {
7 | [key: string]: string | undefined
8 | },
9 | fieldError?: FieldError,
10 | ): string | undefined {
11 | if (!fieldError) return undefined
12 |
13 | for (const key in typeMap) {
14 | if (fieldError.type === key) {
15 | return typeMap[key]
16 | }
17 | }
18 |
19 | return fieldError.message
20 | }
21 |
--------------------------------------------------------------------------------
/src/utils/with-profile.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { useProfile } from '@/store'
4 |
5 | const withProfile = (
6 | Component: React.ComponentType
,
7 | ): React.FC
=>
8 | function WithProfile(props) {
9 | const profile = useProfile()
10 |
11 | if (!profile) {
12 | return null
13 | }
14 |
15 | // @ts-ignore
16 | return
17 | }
18 |
19 | export default withProfile
20 |
--------------------------------------------------------------------------------
/tailwind.config.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | const config = {
3 | darkMode: ['class'],
4 | content: ['./src/**/*.{ts,tsx}'],
5 | theme: {
6 | container: {
7 | center: true,
8 | padding: '2rem',
9 | screens: {
10 | '2xl': '1400px',
11 | },
12 | },
13 | extend: {
14 | colors: {
15 | border: 'hsl(var(--border))',
16 | input: 'hsl(var(--input))',
17 | ring: 'hsl(var(--ring))',
18 | background: 'hsl(var(--background))',
19 | foreground: 'hsl(var(--foreground))',
20 | primary: {
21 | DEFAULT: 'hsl(var(--primary))',
22 | foreground: 'hsl(var(--primary-foreground))',
23 | },
24 | secondary: {
25 | DEFAULT: 'hsl(var(--secondary))',
26 | foreground: 'hsl(var(--secondary-foreground))',
27 | },
28 | destructive: {
29 | DEFAULT: 'hsl(var(--destructive))',
30 | foreground: 'hsl(var(--destructive-foreground))',
31 | },
32 | muted: {
33 | DEFAULT: 'hsl(var(--muted))',
34 | foreground: 'hsl(var(--muted-foreground))',
35 | },
36 | accent: {
37 | DEFAULT: 'hsl(var(--accent))',
38 | foreground: 'hsl(var(--accent-foreground))',
39 | },
40 | switch: {
41 | DEFAULT: 'hsl(var(--switch))',
42 | },
43 | popover: {
44 | DEFAULT: 'hsl(var(--popover))',
45 | foreground: 'hsl(var(--popover-foreground))',
46 | },
47 | card: {
48 | DEFAULT: 'hsl(var(--card))',
49 | foreground: 'hsl(var(--card-foreground))',
50 | },
51 | },
52 | borderRadius: {
53 | lg: 'var(--radius)',
54 | md: 'calc(var(--radius) - 2px)',
55 | sm: 'calc(var(--radius) - 4px)',
56 | },
57 | keyframes: {
58 | 'accordion-down': {
59 | from: { height: 0 },
60 | to: { height: 'var(--radix-accordion-content-height)' },
61 | },
62 | 'accordion-up': {
63 | from: { height: 'var(--radix-accordion-content-height)' },
64 | to: { height: 0 },
65 | },
66 | },
67 | animation: {
68 | 'accordion-down': 'accordion-down 0.2s ease-out',
69 | 'accordion-up': 'accordion-up 0.2s ease-out',
70 | },
71 | },
72 | },
73 | plugins: [require('tailwindcss-animate')],
74 | }
75 |
76 | module.exports = config
77 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2017",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "module": "esnext",
16 | "moduleResolution": "bundler",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "noEmit": true,
20 | "jsx": "react-jsx",
21 | "jsxImportSource": "@emotion/react",
22 | "noFallthroughCasesInSwitch": true,
23 | "baseUrl": ".",
24 | "paths": {
25 | "@/*": [
26 | "./src/*"
27 | ]
28 | }
29 | },
30 | "include": [
31 | "src"
32 | ]
33 | }
34 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "routes": [
3 | {
4 | "src": "/static/(.*)",
5 | "headers": { "cache-control": "public, max-age=31536000" },
6 | "dest": "/static/$1"
7 | }
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------