mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-10 15:35:55 +01:00
✨ Add mantine context modals
This commit is contained in:
137
src/components/Dashboard/Modals/EditService/EditServiceModal.tsx
Normal file
137
src/components/Dashboard/Modals/EditService/EditServiceModal.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import Image from 'next/image';
|
||||
import { Button, createStyles, Group, Stack, Tabs, Text } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { closeModal, ContextModalProps } from '@mantine/modals';
|
||||
import {
|
||||
IconAccessPoint,
|
||||
IconAdjustments,
|
||||
IconBrush,
|
||||
IconClick,
|
||||
IconDeviceFloppy,
|
||||
IconDoorExit,
|
||||
IconPlug,
|
||||
} from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { hideNotification, showNotification } from '@mantine/notifications';
|
||||
import { ServiceType } from '../../../../types/service';
|
||||
import { AppearanceTab } from './Tabs/AppereanceTab/AppereanceTab';
|
||||
import { BehaviourTab } from './Tabs/BehaviourTab/BehaviourTab';
|
||||
import { GeneralTab } from './Tabs/GeneralTab/GeneralTab';
|
||||
import { IntegrationTab } from './Tabs/IntegrationTab/IntegrationTab';
|
||||
import { NetworkTab } from './Tabs/NetworkTab/NetworkTab';
|
||||
|
||||
export const EditServiceModal = ({
|
||||
context,
|
||||
id,
|
||||
innerProps,
|
||||
}: ContextModalProps<{ service: ServiceType }>) => {
|
||||
const { t } = useTranslation();
|
||||
const { classes } = useStyles();
|
||||
|
||||
const form = useForm<ServiceType>({
|
||||
initialValues: innerProps.service,
|
||||
});
|
||||
|
||||
const onSubmit = (values: ServiceType) => {
|
||||
console.log('form submitted');
|
||||
console.log(values);
|
||||
};
|
||||
|
||||
const tryCloseModal = () => {
|
||||
if (form.isDirty()) {
|
||||
showNotification({
|
||||
id: 'unsaved-edit-service-modal-changes',
|
||||
title: 'You have unsaved changes',
|
||||
message: (
|
||||
<Stack>
|
||||
<Text color="dimmed">If you close, your changes will be discarded and not saved.</Text>
|
||||
<Button
|
||||
onClick={() => {
|
||||
context.closeModal(id);
|
||||
hideNotification('unsaved-edit-service-modal-changes');
|
||||
}}
|
||||
variant="light"
|
||||
>
|
||||
Close anyway
|
||||
</Button>
|
||||
</Stack>
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
context.closeModal(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack spacing={0} align="center" my="lg">
|
||||
{form.values.appearance.iconUrl ? (
|
||||
// disabled because image target is too dynamic for next image cache
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className={classes.serviceImage}
|
||||
src={form.values.appearance.iconUrl}
|
||||
width={120}
|
||||
height={120}
|
||||
alt="service icon"
|
||||
/>
|
||||
) : (
|
||||
<Image src="/favicon-squared.png" width={120} height={120} />
|
||||
)}
|
||||
|
||||
<Text align="center" weight="bold" size="lg" mt="md">
|
||||
{form.values.name ?? 'New Service'}
|
||||
</Text>
|
||||
</Stack>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Tabs defaultValue="general">
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="general" icon={<IconAdjustments size={14} />}>
|
||||
General
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="behaviour" icon={<IconClick size={14} />}>
|
||||
Behaviour
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="network" icon={<IconAccessPoint size={14} />}>
|
||||
Network
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="appearance" icon={<IconBrush size={14} />}>
|
||||
Appearance
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="integration" icon={<IconPlug size={14} />}>
|
||||
Integration
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<GeneralTab form={form} />
|
||||
<BehaviourTab form={form} />
|
||||
<NetworkTab form={form} />
|
||||
<AppearanceTab form={form} />
|
||||
<IntegrationTab form={form} />
|
||||
</Tabs>
|
||||
|
||||
<Group position="right" mt={100}>
|
||||
<Button
|
||||
leftIcon={<IconDoorExit size={20} />}
|
||||
px={50}
|
||||
variant="light"
|
||||
color="gray"
|
||||
onClick={tryCloseModal}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" leftIcon={<IconDeviceFloppy size={20} />} px={50}>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
serviceImage: {
|
||||
objectFit: 'contain',
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Tabs, TextInput } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { IconPhoto } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ServiceType } from '../../../../../../types/service';
|
||||
|
||||
interface AppearanceTabProps {
|
||||
form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>;
|
||||
}
|
||||
|
||||
export const AppearanceTab = ({ form }: AppearanceTabProps) => {
|
||||
const { t } = useTranslation('');
|
||||
return (
|
||||
<Tabs.Panel value="appearance" pt="lg">
|
||||
<TextInput
|
||||
icon={<IconPhoto />}
|
||||
label="Service Icon"
|
||||
variant="default"
|
||||
defaultValue={form.values.appearance.iconUrl}
|
||||
{...form.getInputProps('serviceIcon')}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Tabs, TextInput, Switch, Text } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { IconClick } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ServiceType } from '../../../../../../types/service';
|
||||
|
||||
interface BehaviourTabProps {
|
||||
form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>;
|
||||
}
|
||||
|
||||
export const BehaviourTab = ({ form }: BehaviourTabProps) => {
|
||||
const { t } = useTranslation('');
|
||||
return (
|
||||
<Tabs.Panel value="behaviour" pt="xs">
|
||||
<TextInput
|
||||
icon={<IconClick size={16} />}
|
||||
label="On click url"
|
||||
placeholder="Override the default service url when clicking on the service"
|
||||
variant="default"
|
||||
mb="md"
|
||||
{...form.getInputProps('onClickUrl')}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
value="disable_handle"
|
||||
label="Disable direct moving in edit modus"
|
||||
description={
|
||||
<Text color="dimmed" size="sm">
|
||||
Disables the direct movement of the tile
|
||||
</Text>
|
||||
}
|
||||
mb="md"
|
||||
{...form.getInputProps('isEditModeMovingDisabled')}
|
||||
/>
|
||||
<Switch
|
||||
value="freze"
|
||||
label="Freeze tile within edit modus"
|
||||
description={
|
||||
<Text color="dimmed" size="sm">
|
||||
Disables the movement of the tile when moving others
|
||||
</Text>
|
||||
}
|
||||
{...form.getInputProps('isEditModeTileFreezed')}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Tabs, TextInput } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { IconCursorText, IconLink } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ServiceType } from '../../../../../../types/service';
|
||||
|
||||
interface GeneralTabProps {
|
||||
form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>;
|
||||
}
|
||||
|
||||
export const GeneralTab = ({ form }: GeneralTabProps) => {
|
||||
const { t } = useTranslation('');
|
||||
return (
|
||||
<Tabs.Panel value="general" pt="lg">
|
||||
<TextInput
|
||||
icon={<IconCursorText size={16} />}
|
||||
label="Service name"
|
||||
placeholder="My example service"
|
||||
variant="default"
|
||||
mb="md"
|
||||
required
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
icon={<IconLink size={16} />}
|
||||
label="Service url"
|
||||
placeholder="https://google.com"
|
||||
variant="default"
|
||||
required
|
||||
{...form.getInputProps('url')}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Card,
|
||||
createStyles,
|
||||
Flex,
|
||||
Grid,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconDeviceFloppy } from '@tabler/icons';
|
||||
import { ReactNode, useState } from 'react';
|
||||
|
||||
interface GenericSecretInputProps {
|
||||
label: string;
|
||||
value: string;
|
||||
secretIsPresent: boolean;
|
||||
unsetIcon: ReactNode;
|
||||
setIcon: ReactNode;
|
||||
}
|
||||
|
||||
export const GenericSecretInput = ({
|
||||
label,
|
||||
value,
|
||||
secretIsPresent,
|
||||
setIcon,
|
||||
unsetIcon,
|
||||
}: GenericSecretInputProps) => {
|
||||
const { classes } = useStyles();
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Grid>
|
||||
<Grid.Col className={classes.alignSelfCenter} xs={12} md={6}>
|
||||
<Group spacing="sm">
|
||||
<ThemeIcon color={secretIsPresent ? 'green' : 'red'} variant="light">
|
||||
{secretIsPresent ? setIcon : unsetIcon}
|
||||
</ThemeIcon>
|
||||
<Stack spacing={0}>
|
||||
<Title className={classes.subtitle} order={6}>
|
||||
{label}
|
||||
</Title>
|
||||
<Text size="xs" color="dimmed">
|
||||
{secretIsPresent
|
||||
? 'Secret is defined in the configuration'
|
||||
: 'Secret has not been defined'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col xs={12} md={6}>
|
||||
<Flex gap={10} justify="end" align="end">
|
||||
{secretIsPresent ? (
|
||||
<>
|
||||
<Button variant="subtle" color="gray" px="xl">
|
||||
Clear Secret
|
||||
</Button>
|
||||
<TextInput
|
||||
type="password"
|
||||
placeholder="Leave empty"
|
||||
description={`Update secret${dirty ? ' (unsaved)' : ''}`}
|
||||
rightSection={
|
||||
<ActionIcon disabled={!dirty}>
|
||||
<IconDeviceFloppy size={18} />
|
||||
</ActionIcon>
|
||||
}
|
||||
defaultValue={value}
|
||||
onChange={() => setDirty(true)}
|
||||
withAsterisk
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="light" px="xl">
|
||||
Define secret
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
subtitle: {
|
||||
lineHeight: 1.1,
|
||||
},
|
||||
alignSelfCenter: {
|
||||
alignSelf: 'center',
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,139 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {
|
||||
Alert,
|
||||
Card,
|
||||
Group,
|
||||
PasswordInput,
|
||||
Select,
|
||||
SelectItem,
|
||||
Space,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { IconKey, IconUser } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { forwardRef, useState } from 'react';
|
||||
import { ServiceType } from '../../../../../../../../../types/service';
|
||||
import { TextExplanation } from '../TextExplanation/TextExplanation';
|
||||
|
||||
interface IntegrationSelectorProps {
|
||||
form: UseFormReturnType<ServiceType, (item: ServiceType) => ServiceType>;
|
||||
}
|
||||
|
||||
export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
|
||||
const { t } = useTranslation('');
|
||||
|
||||
// TODO: read this out from integrations dynamically.
|
||||
const data: SelectItem[] = [
|
||||
{
|
||||
value: 'sabnzbd',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/sabnzbd.png',
|
||||
label: 'SABnzbd',
|
||||
},
|
||||
{
|
||||
value: 'deluge',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/deluge.png',
|
||||
label: 'Deluge',
|
||||
},
|
||||
{
|
||||
value: 'transmission',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/transmission.png',
|
||||
label: 'Transmission',
|
||||
},
|
||||
{
|
||||
value: 'qbittorrent',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/qbittorrent.png',
|
||||
label: 'qBittorrent',
|
||||
},
|
||||
{
|
||||
value: 'jellyseerr',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/jellyseerr.png',
|
||||
label: 'Jellyseerr',
|
||||
},
|
||||
{
|
||||
value: 'overseerr',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/overseerr.png',
|
||||
label: 'Overseerr',
|
||||
},
|
||||
];
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<SelectItem>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextExplanation />
|
||||
<Space h="sm" />
|
||||
|
||||
<Select
|
||||
label="Configure this service for the following integration"
|
||||
placeholder="Select your desired configuration"
|
||||
itemComponent={SelectItemComponent}
|
||||
data={data}
|
||||
maxDropdownHeight={400}
|
||||
clearable
|
||||
onSelect={(e) => {
|
||||
const item = data.find((x) => x.label === e.currentTarget.value);
|
||||
|
||||
if (item === undefined) {
|
||||
setSelectedItem(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedItem(item);
|
||||
}}
|
||||
variant="default"
|
||||
mb="md"
|
||||
icon={selectedItem && <img src={selectedItem.image} alt="test" width={20} height={20} />}
|
||||
/>
|
||||
|
||||
{/*
|
||||
{selectedItem && (
|
||||
<Card p="md" pt="sm" radius="sm">
|
||||
<Text weight={500} mb="lg">
|
||||
Integration Configuration
|
||||
</Text>
|
||||
|
||||
<Group grow>
|
||||
<TextInput
|
||||
icon={<IconUser size={16} />}
|
||||
label="Username"
|
||||
description="Optional"
|
||||
placeholder="deluge"
|
||||
variant="default"
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
icon={<IconKey />}
|
||||
label="Password"
|
||||
description="Optional, never share this with anybody else"
|
||||
variant="default"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
*/}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ItemProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||
image: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const SelectItemComponent = forwardRef<HTMLDivElement, ItemProps>(
|
||||
({ image, label, ...others }: ItemProps, ref) => (
|
||||
<div ref={ref} {...others}>
|
||||
<Group noWrap>
|
||||
<img src={image} alt="integration icon" width={20} height={20} />
|
||||
|
||||
<div>
|
||||
<Text size="sm">{label}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Stack } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { IconKey, IconKeyOff, IconLock, IconLockOff, IconUser, IconUserOff } from '@tabler/icons';
|
||||
import { ServiceType } from '../../../../../../../../../types/service';
|
||||
import { GenericSecretInput } from '../InputElements/GenericSecretInput';
|
||||
|
||||
interface IntegrationOptionsRendererProps {
|
||||
form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>;
|
||||
}
|
||||
|
||||
const secretMappings = [
|
||||
{
|
||||
label: 'username',
|
||||
prettyName: 'Username',
|
||||
icon: <IconUser size={18} />,
|
||||
iconUnset: <IconUserOff size={18} />,
|
||||
},
|
||||
{
|
||||
label: 'password',
|
||||
prettyName: 'Password',
|
||||
icon: <IconLock size={18} />,
|
||||
iconUnset: <IconLockOff size={18} />,
|
||||
},
|
||||
{
|
||||
label: 'apiKey',
|
||||
prettyName: 'API Key',
|
||||
icon: <IconKey size={18} />,
|
||||
iconUnset: <IconKeyOff size={18} />,
|
||||
},
|
||||
];
|
||||
|
||||
export const IntegrationOptionsRenderer = ({ form }: IntegrationOptionsRendererProps) => (
|
||||
<Stack spacing="xs" mb="md">
|
||||
{Object.entries(form.values.integration.properties).map((entry) => {
|
||||
const mapping = secretMappings.find((item) => item.label === entry[0]);
|
||||
const isPresent = entry[1] !== undefined;
|
||||
|
||||
if (!mapping) {
|
||||
return (
|
||||
<GenericSecretInput
|
||||
label={`${entry[0]} (potentionally unmapped)`}
|
||||
value={entry[1]}
|
||||
secretIsPresent={isPresent}
|
||||
setIcon={<IconKey size={18} />}
|
||||
unsetIcon={<IconKeyOff size={18} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericSecretInput
|
||||
label={mapping.prettyName}
|
||||
value={entry[1]}
|
||||
secretIsPresent={isPresent}
|
||||
setIcon={mapping.icon}
|
||||
unsetIcon={mapping.iconUnset}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Text } from '@mantine/core';
|
||||
|
||||
export const TextExplanation = () => {
|
||||
return (
|
||||
<Text color="dimmed">
|
||||
You can optionally connect your services using integrations. Integration elements on your
|
||||
dashboard will communicate to your services using the configuration below.
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Alert, Divider, Tabs, Text } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { IconAlertTriangle } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ServiceType } from '../../../../../../types/service';
|
||||
import { IntegrationSelector } from './Components/InputElements/IntegrationSelector';
|
||||
import { IntegrationOptionsRenderer } from './Components/IntegrationOptionsRenderer/IntegrationOptionsRenderer';
|
||||
|
||||
interface IntegrationTabProps {
|
||||
form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>;
|
||||
}
|
||||
|
||||
export const IntegrationTab = ({ form }: IntegrationTabProps) => {
|
||||
const { t } = useTranslation('');
|
||||
const hasIntegrationSelected = form.values.integration !== null;
|
||||
|
||||
return (
|
||||
<Tabs.Panel value="integration" pt="lg">
|
||||
<IntegrationSelector form={form} />
|
||||
|
||||
{hasIntegrationSelected && (
|
||||
<>
|
||||
<Divider label="Integration Configuration" labelPosition="center" mt="xl" mb="md" />
|
||||
<IntegrationOptionsRenderer form={form} />
|
||||
<Alert icon={<IconAlertTriangle />} color="yellow">
|
||||
<Text>
|
||||
Please note that Homarr removes secrets from the configuration for security reasons.
|
||||
Thus, you can only either define or unset any credentials. Your credentials act as the
|
||||
main access for your integrations and you should <b>never</b> share them with anybody
|
||||
else. Make sure to <b>store and manage your secrets safely</b>.
|
||||
</Text>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Tabs, Switch, MultiSelect } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { StatusCodes } from '../../../../../../tools/acceptableStatusCodes';
|
||||
import { ServiceType } from '../../../../../../types/service';
|
||||
|
||||
interface NetworkTabProps {
|
||||
form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>;
|
||||
}
|
||||
|
||||
export const NetworkTab = ({ form }: NetworkTabProps) => {
|
||||
const { t } = useTranslation('');
|
||||
return (
|
||||
<Tabs.Panel value="network" pt="lg">
|
||||
<Switch
|
||||
label="Enable status checker"
|
||||
mb="md"
|
||||
defaultChecked={form.values.network.enabledStatusChecker}
|
||||
{...form.getInputProps('network.enabledStatusChecker')}
|
||||
/>
|
||||
{form.values.network.enabledStatusChecker && (
|
||||
<MultiSelect
|
||||
required
|
||||
label="HTTP status codes"
|
||||
data={StatusCodes}
|
||||
clearable
|
||||
searchable
|
||||
defaultValue={form.values.network.okStatus}
|
||||
variant="default"
|
||||
{...form.getInputProps('network.statusCodes')}
|
||||
/>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user