diff --git a/package.json b/package.json index 0d9d741f38a6b8ea8e735a2c616be17f112f0259..dea5f4ed4b04af94d6fcdc2827e7e3dfe5df8efc 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@hookform/resolvers": "^4.1.2", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.6", @@ -18,6 +19,7 @@ "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", "@tailwindcss/vite": "^4.0.9", "class-variance-authority": "^0.7.1", @@ -26,11 +28,13 @@ "next-themes": "^0.4.4", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", "react-router": "^7.2.0", "sonner": "^2.0.1", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.9", "tailwindcss-animate": "^1.0.7", + "zod": "^3.24.2", "zustand": "^5.0.3" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9732eb25ebd56329766a2bfa721ab08fcbf762b..dea70bbcf9bf90645183779deabfdd35f4537217 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@hookform/resolvers': + specifier: ^4.1.2 + version: 4.1.2(react-hook-form@7.54.2(react@19.0.0)) '@radix-ui/react-avatar': specifier: ^1.1.3 version: 1.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -32,6 +35,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.1.2 version: 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-switch': + specifier: ^1.1.3 + version: 1.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-tooltip': specifier: ^1.1.8 version: 1.1.8(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -56,6 +62,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.0.0(react@19.0.0) + react-hook-form: + specifier: ^7.54.2 + version: 7.54.2(react@19.0.0) react-router: specifier: ^7.2.0 version: 7.2.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -71,6 +80,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@4.0.9) + zod: + specifier: ^3.24.2 + version: 3.24.2 zustand: specifier: ^5.0.3 version: 5.0.3(@types/react@19.0.10)(react@19.0.0) @@ -313,6 +325,11 @@ packages: '@floating-ui/utils@0.2.9': resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} + '@hookform/resolvers@4.1.2': + resolution: {integrity: sha512-wl6H9c9wLOZMJAqGLEVKzbCkxJuV+BYuLFZFCQtCwMe0b3qQk4kUBd/ZAj13SwcSqcx86rCgSCyngQfmA6DOWg==} + peerDependencies: + react-hook-form: ^7.0.0 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -623,6 +640,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-switch@1.1.3': + resolution: {integrity: sha512-1nc+vjEOQkJVsJtWPSiISGT6OKm4SiOdjMo+/icLxo2G4vxz1GntC5MzfL4v8ey9OEfw787QCD1y3mUv0NiFEQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-tooltip@1.1.8': resolution: {integrity: sha512-YAA2cu48EkJZdAMHC0dqo9kialOcRStbtiY4nJPaht7Ptrhcvpo+eDChaM6BIs8kL6a8Z5l5poiqLnXcNduOkA==} peerDependencies: @@ -672,6 +702,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-previous@1.1.0': + resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-rect@1.1.0': resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} peerDependencies: @@ -801,6 +840,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@swc/core-darwin-arm64@1.11.4': resolution: {integrity: sha512-Oi4lt4wqjpp80pcCh+vzvpsESJ8XXozYCE5EM/dDpr+9m2oRpkseds7Gq4ulzgdbUDPo1jJ1PonjjrKpfKY+sQ==} engines: {node: '>=10'} @@ -1465,6 +1507,12 @@ packages: peerDependencies: react: ^19.0.0 + react-hook-form@7.54.2: + resolution: {integrity: sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -1688,6 +1736,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@3.24.2: + resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} + zustand@5.0.3: resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==} engines: {node: '>=12.20.0'} @@ -1842,6 +1893,11 @@ snapshots: '@floating-ui/utils@0.2.9': {} + '@hookform/resolvers@4.1.2(react-hook-form@7.54.2(react@19.0.0))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.54.2(react@19.0.0) + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -2142,6 +2198,21 @@ snapshots: optionalDependencies: '@types/react': 19.0.10 + '@radix-ui/react-switch@1.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-context': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-previous': 1.1.0(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-size': 1.1.0(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + '@radix-ui/react-tooltip@1.1.8(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -2188,6 +2259,12 @@ snapshots: optionalDependencies: '@types/react': 19.0.10 + '@radix-ui/react-use-previous@1.1.0(@types/react@19.0.10)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + '@radix-ui/react-use-rect@1.1.0(@types/react@19.0.10)(react@19.0.0)': dependencies: '@radix-ui/rect': 1.1.0 @@ -2270,6 +2347,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.34.8': optional: true + '@standard-schema/utils@0.3.0': {} + '@swc/core-darwin-arm64@1.11.4': optional: true @@ -2903,6 +2982,10 @@ snapshots: react: 19.0.0 scheduler: 0.25.0 + react-hook-form@7.54.2(react@19.0.0): + dependencies: + react: 19.0.0 + react-remove-scroll-bar@2.3.8(@types/react@19.0.10)(react@19.0.0): dependencies: react: 19.0.0 @@ -3078,6 +3161,8 @@ snapshots: yocto-queue@0.1.0: {} + zod@3.24.2: {} + zustand@5.0.3(@types/react@19.0.10)(react@19.0.0): optionalDependencies: '@types/react': 19.0.10 diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2e2a16af6ba92f7507972235c7af6cd8efb73173 --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,149 @@ +import * as React from 'react'; +import * as LabelPrimitive from '@radix-ui/react-label'; +import { Slot } from '@radix-ui/react-slot'; +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, + useFormState, +} from 'react-hook-form'; + +import { cn } from '@/lib/utils'; +import { Label } from '@/components/ui/label'; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> +> = { + name: TName; +}; + +const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> +>({ + ...props +}: ControllerProps<TFieldValues, TName>) => { + return ( + <FormFieldContext.Provider value={{ name: props.name }}> + <Controller {...props} /> + </FormFieldContext.Provider> + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState } = useFormContext(); + const formState = useFormState({ name: fieldContext.name }); + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error('useFormField should be used within <FormField>'); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue); + +function FormItem({ className, ...props }: React.ComponentProps<'div'>) { + const id = React.useId(); + + return ( + <FormItemContext.Provider value={{ id }}> + <div data-slot="form-item" className={cn('grid gap-2', className)} {...props} /> + </FormItemContext.Provider> + ); +} + +function FormLabel({ + className, + required, + ...props +}: React.ComponentProps<typeof LabelPrimitive.Root> & { required?: boolean }) { + const { error, formItemId } = useFormField(); + + return ( + <Label + data-slot="form-label" + data-error={!!error} + className={cn( + 'data-[error=true]:text-destructive-foreground', + required && 'gap-0 after:ml-1 after:text-destructive after:content-["*"]', + className + )} + htmlFor={formItemId} + {...props} + /> + ); +} + +function FormControl({ ...props }: React.ComponentProps<typeof Slot>) { + const { error, formItemId, formDescriptionId, formMessageId } = useFormField(); + + return ( + <Slot + data-slot="form-control" + id={formItemId} + aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`} + aria-invalid={!!error} + {...props} + /> + ); +} + +function FormDescription({ className, ...props }: React.ComponentProps<'p'>) { + const { formDescriptionId } = useFormField(); + + return ( + <p + data-slot="form-description" + id={formDescriptionId} + className={cn('text-muted-foreground text-[0.8rem]', className)} + {...props} + /> + ); +} + +function FormMessage({ className, ...props }: React.ComponentProps<'p'>) { + const { error, formMessageId } = useFormField(); + const body = error ? String(error?.message ?? '') : props.children; + + if (!body) { + return null; + } + + return ( + <p + data-slot="form-message" + id={formMessageId} + className={cn('text-destructive-foreground text-[0.8rem]', className)} + {...props} + > + {body} + </p> + ); +} + +export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField }; diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fce06f798dbc573ad80de39b6f601defb5c416b7 --- /dev/null +++ b/src/components/ui/switch.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as SwitchPrimitive from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +function Switch({ + className, + ...props +}: React.ComponentProps<typeof SwitchPrimitive.Root>) { + return ( + <SwitchPrimitive.Root + data-slot="switch" + className={cn( + "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-5 w-9 shrink-0 items-center rounded-full border-2 border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", + className + )} + {...props} + > + <SwitchPrimitive.Thumb + data-slot="switch-thumb" + className={cn( + "bg-background pointer-events-none block size-4 rounded-full ring-0 shadow-lg transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" + )} + /> + </SwitchPrimitive.Root> + ) +} + +export { Switch } diff --git a/src/pages/routing/Create.tsx b/src/pages/routing/Create.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7bb5de35abd35589a43ae1d192ff79a844b4aa3e --- /dev/null +++ b/src/pages/routing/Create.tsx @@ -0,0 +1,173 @@ +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Link } from 'react-router'; +import { useForm } from 'react-hook-form'; +import { Check, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { Separator } from '@/components/ui/separator'; +import { toast } from 'sonner'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; + +const formSchema = z.object({ + name: z.string({ required_error: '서버 이름을 입력해주세요' }).min(1, { message: '서버 이름을 입력해주세요' }), + domain: z + .string({ required_error: '도메인 주소를 입력해주세요' }) + .regex(/^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, { + message: '올바른 도메인 주소를 입력해주세요', + }), + instance_ip: z + .string({ required_error: '인스턴스 IP를 입력해주세요' }) + .regex(/^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/, { + message: '올바른 IP 주소를 입력해주세요', + }) + .startsWith('10.16.', { message: '인스턴스 IP는 10.16.0.0/16 대역을 사용해야 합니다' }), + enable_ssl: z.boolean(), + enable_cache: z.boolean(), +}); + +export default function RoutingCreate() { + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: '', + domain: '', + instance_ip: '', + enable_ssl: false, + enable_cache: false, + }, + }); + + function onSubmit(values: z.infer<typeof formSchema>) { + console.log('제출된 데이터:', values); + toast.warning('라우팅 설정을 등록합니다'); + } + + return ( + <div className="flex flex-1 flex-col gap-4 p-6"> + <div className="mb-2"> + <h1 className="scroll-m-20 text-3xl font-semibold first:mt-0">신규 라우팅 등록</h1> + <p className="mt-1 text-base text-gray-500">웹 프록시 서버에 새로운 라우팅 설정을 등록합니다.</p> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <Card className="mb-4"> + <CardHeader> + <CardTitle className="text-xl">라우팅 기본 설정</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-1 gap-4"> + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel required>서버 이름</FormLabel> + <FormControl> + <Input placeholder="웹 서버 이름" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="domain" + render={({ field }) => ( + <FormItem> + <FormLabel required>도메인</FormLabel> + <FormControl> + <Input placeholder="example.ajou.app" {...field} /> + </FormControl> + <FormDescription>*.ajou.app 도메인은 별도 설정 없이 자유롭게 사용할 수 있습니다</FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="instance_ip" + render={({ field }) => ( + <FormItem> + <FormLabel required>인스턴스 IP</FormLabel> + <FormControl> + <Input placeholder="10.16.x.x" {...field} /> + </FormControl> + <FormDescription>인스턴스 IP는 10.16.0.0/16 대역을 사용합니다</FormDescription> + <FormMessage /> + </FormItem> + )} + /> + </div> + </CardContent> + </Card> + + <Card className="mb-6"> + <CardHeader> + <CardTitle className="text-xl">추가 설정</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <FormField + control={form.control} + name="enable_ssl" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between"> + <div className="space-y-0.5"> + <FormLabel className="text-base font-medium">SSL 보안 연결</FormLabel> + <FormDescription className="text-sm text-gray-500"> + HTTPS 프로토콜을 사용하여 보안 연결을 활성화합니다 + </FormDescription> + </div> + <FormControl> + <div className="flex items-center space-x-2"> + {!field.value && <X className="h-4 w-4 text-gray-500" />} + {field.value && <Check className="h-4 w-4" />} + <Switch checked={field.value} onCheckedChange={field.onChange} /> + </div> + </FormControl> + </FormItem> + )} + /> + + <Separator /> + + <FormField + control={form.control} + name="enable_cache" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between"> + <div className="space-y-0.5"> + <FormLabel className="text-base font-medium">정적 파일 캐싱 활성화</FormLabel> + <FormDescription className="text-sm text-gray-500"> + 빠른 응답을 위해 정적 파일에 대한 캐싱을 활성화합니다 + </FormDescription> + </div> + <FormControl> + <div className="flex items-center space-x-2"> + {!field.value && <X className="h-4 w-4 text-gray-500" />} + {field.value && <Check className="h-4 w-4" />} + <Switch checked={field.value} onCheckedChange={field.onChange} /> + </div> + </FormControl> + </FormItem> + )} + /> + </CardContent> + </Card> + + <div className="flex justify-end gap-2"> + <Button variant="outline" asChild> + <Link to="..">취소</Link> + </Button> + <Button type="submit">저장</Button> + </div> + </form> + </Form> + </div> + ); +} diff --git a/src/pages/routing/List.tsx b/src/pages/routing/List.tsx index 4b19ab265fa5f5feb1f48547d20e43d004646329..26b4fa1c10b8f8dde23dc7f0fe2929ecc82e0c92 100644 --- a/src/pages/routing/List.tsx +++ b/src/pages/routing/List.tsx @@ -58,8 +58,10 @@ export default function RoutingList() { <h1 className="scroll-m-20 text-3xl font-semibold first:mt-0">웹 라우팅 설정</h1> <p className="mt-1 text-base text-gray-500">현재 3개의 웹 프록시가 설정되어 있습니다.</p> </div> - <Button className="ml-2"> - <Plus className="h-4 w-4" /> 새 프록시 추가 + <Button className="ml-2" asChild> + <Link to="./create"> + <Plus className="h-4 w-4" /> 새 프록시 추가 + </Link> </Button> </div> <Card> diff --git a/src/routes.tsx b/src/routes.tsx index 047e98fa5470192fd9facb0403ecc914676d0116..2630ccf886d43ea0ac9d6969c9775d31885c4bca 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -4,6 +4,7 @@ import NotFound from '@/pages/NotFound'; import Home from '@/pages/Home'; import Login from '@/pages/Login'; import RoutingList from '@/pages/routing/List'; +import RoutingCreate from '@/pages/routing/Create'; export default function AppRoutes() { return ( @@ -13,6 +14,7 @@ export default function AppRoutes() { <Route path="login" element={<Login />} /> <Route path="routing"> <Route index element={<RoutingList />} /> + <Route path="create" element={<RoutingCreate />} /> </Route> <Route path="*" element={<NotFound />} /> </Route>