mirror of
https://github.com/diced/zipline.git
synced 2025-12-12 07:40:45 -08:00
feat: small ui improvements
This commit is contained in:
@@ -2,7 +2,8 @@ import { Response } from '@/lib/api/response';
|
||||
import { Alert, Anchor, Collapse, Group, SimpleGrid, Skeleton, Stack, Title } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import useSWR from 'swr';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { lazy, Suspense, useMemo } from 'react';
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
|
||||
const Core = lazy(() => import('./parts/Core'));
|
||||
const Chunks = lazy(() => import('./parts/Chunks'));
|
||||
@@ -30,6 +31,41 @@ export default function DashboardServerSettings() {
|
||||
const { data, isLoading, error } = useSWR<Response['/api/server/settings']>('/api/server/settings');
|
||||
const [opened, { toggle }] = useDisclosure(false);
|
||||
|
||||
const scrollToSetting = useMemo(() => {
|
||||
return (setting: string) => {
|
||||
console.log('scrolling to setting:', setting);
|
||||
const input = document.querySelector<HTMLInputElement>(`[data-path="${setting}"]`);
|
||||
if (input) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
observer.disconnect();
|
||||
const parent = input.parentElement?.parentElement;
|
||||
if (parent) {
|
||||
parent.style.transition = 'transform 0.35s';
|
||||
parent.style.transform = 'scale(1.2)';
|
||||
setTimeout(() => {
|
||||
parent.style.transform = 'scale(1)';
|
||||
}, 350);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 1.0 },
|
||||
);
|
||||
observer.observe(input);
|
||||
|
||||
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
input.focus();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onTamperedClick = (e: React.MouseEvent<HTMLAnchorElement>, setting: string) => {
|
||||
e.preventDefault();
|
||||
|
||||
scrollToSetting(setting);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group gap='sm'>
|
||||
@@ -49,7 +85,9 @@ export default function DashboardServerSettings() {
|
||||
<Collapse in={opened} transitionDuration={200}>
|
||||
<ul>
|
||||
{data!.tampered.map((setting) => (
|
||||
<li key={setting}>{setting}</li>
|
||||
<li key={setting}>
|
||||
<Anchor onClick={(e) => onTamperedClick(e, setting)}>{setting}</Anchor>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Collapse>
|
||||
@@ -74,11 +112,12 @@ export default function DashboardServerSettings() {
|
||||
</Stack>
|
||||
|
||||
<Ratelimit swr={{ data, isLoading }} />
|
||||
<Website swr={{ data, isLoading }} />
|
||||
<Stack>
|
||||
<Website swr={{ data, isLoading }} />
|
||||
<PWA swr={{ data, isLoading }} />
|
||||
</Stack>
|
||||
<Oauth swr={{ data, isLoading }} />
|
||||
|
||||
<PWA swr={{ data, isLoading }} />
|
||||
|
||||
<HttpWebhook swr={{ data, isLoading }} />
|
||||
|
||||
<Domains swr={{ data, isLoading }} />
|
||||
|
||||
@@ -148,32 +148,34 @@ export default function Oauth({
|
||||
{...form.getInputProps('oauthLoginOnly', { type: 'checkbox' })}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Paper withBorder p='sm'>
|
||||
<Anchor href='https://discord.com/developers/applications' target='_blank'>
|
||||
<Title order={4} mb='sm'>
|
||||
Discord
|
||||
</Title>
|
||||
</Anchor>
|
||||
|
||||
<TextInput label='Discord Client ID' {...form.getInputProps('oauthDiscordClientId')} />
|
||||
<TextInput label='Discord Client Secret' {...form.getInputProps('oauthDiscordClientSecret')} />
|
||||
<TextInput
|
||||
label='Discord Allowed IDs'
|
||||
description='A comma-separated list of Discord user IDs that are allowed to log in. Leave empty to disable allow list.'
|
||||
{...form.getInputProps('oauthDiscordAllowedIds')}
|
||||
/>
|
||||
<TextInput
|
||||
label='Discord Denied IDs'
|
||||
description='A comma-separated list of Discord user IDs that are denied from logging in. Leave empty to disable deny list.'
|
||||
{...form.getInputProps('oauthDiscordDeniedIds')}
|
||||
/>
|
||||
<TextInput
|
||||
label='Discord Redirect URL'
|
||||
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
|
||||
{...form.getInputProps('oauthDiscordRedirectUri')}
|
||||
/>
|
||||
</Paper>
|
||||
<Paper withBorder p='sm' my='sm'>
|
||||
<Anchor href='https://discord.com/developers/applications' target='_blank'>
|
||||
<Title order={4} mb='sm'>
|
||||
Discord
|
||||
</Title>
|
||||
</Anchor>
|
||||
|
||||
<TextInput label='Discord Client ID' {...form.getInputProps('oauthDiscordClientId')} />
|
||||
<TextInput label='Discord Client Secret' {...form.getInputProps('oauthDiscordClientSecret')} />
|
||||
<TextInput
|
||||
label='Discord Allowed IDs'
|
||||
description='A comma-separated list of Discord user IDs that are allowed to log in. Leave empty to disable allow list.'
|
||||
{...form.getInputProps('oauthDiscordAllowedIds')}
|
||||
/>
|
||||
<TextInput
|
||||
label='Discord Denied IDs'
|
||||
description='A comma-separated list of Discord user IDs that are denied from logging in. Leave empty to disable deny list.'
|
||||
{...form.getInputProps('oauthDiscordDeniedIds')}
|
||||
/>
|
||||
<TextInput
|
||||
label='Discord Redirect URL'
|
||||
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
|
||||
{...form.getInputProps('oauthDiscordRedirectUri')}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Paper withBorder p='sm'>
|
||||
<Anchor href='https://console.developers.google.com/' target='_blank'>
|
||||
<Title order={4} mb='sm'>
|
||||
@@ -189,16 +191,14 @@ export default function Oauth({
|
||||
{...form.getInputProps('oauthGoogleRedirectUri')}
|
||||
/>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper withBorder p='sm' my='md'>
|
||||
<Anchor href='https://github.com/settings/developers' target='_blank'>
|
||||
<Title order={4} mb='sm'>
|
||||
GitHub
|
||||
</Title>
|
||||
</Anchor>
|
||||
<Paper withBorder p='sm'>
|
||||
<Anchor href='https://github.com/settings/developers' target='_blank'>
|
||||
<Title order={4} mb='sm'>
|
||||
GitHub
|
||||
</Title>
|
||||
</Anchor>
|
||||
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<TextInput label='GitHub Client ID' {...form.getInputProps('oauthGithubClientId')} />
|
||||
<TextInput label='GitHub Client Secret' {...form.getInputProps('oauthGithubClientSecret')} />
|
||||
<TextInput
|
||||
@@ -206,8 +206,8 @@ export default function Oauth({
|
||||
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
|
||||
{...form.getInputProps('oauthGithubRedirectUri')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper withBorder p='sm' my='md'>
|
||||
<Title order={4}>OpenID Connect</Title>
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function PWA({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<Paper withBorder p='sm' pos='relative' h='100%'>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>PWA</Title>
|
||||
|
||||
@@ -120,7 +120,7 @@ export default function Ratelimit({
|
||||
<TextInput
|
||||
label='Allow List'
|
||||
description='A comma-separated list of IP addresses to bypass the ratelimit.'
|
||||
placeholder='1.1.1.1, 8.8.8.8'
|
||||
placeholder='192.168.1.1, 127.0.0.1, 0.0.0.0'
|
||||
{...form.getInputProps('ratelimitAllowList')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function SettingsDashboard() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm'>
|
||||
<Paper withBorder p='sm' h='100%'>
|
||||
<Title order={2}>Dashboard Settings</Title>
|
||||
<Text size='sm' c='dimmed' mt={3}>
|
||||
These settings are saved in your browser.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { ActionIcon, Button, Group, Paper, ScrollArea, Table, Title } from '@mantine/core';
|
||||
import { ActionIcon, Button, Group, Paper, ScrollArea, Table, Text, Title, Tooltip } from '@mantine/core';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconDownload, IconPlus, IconTrashFilled } from '@tabler/icons-react';
|
||||
@@ -64,53 +65,64 @@ export default function SettingsExports() {
|
||||
New Export
|
||||
</Button>
|
||||
|
||||
<Title order={4} mt='sm'>
|
||||
Exports
|
||||
</Title>
|
||||
|
||||
{data?.length === 0 ? (
|
||||
<Paper p='sm' mt='sm' withBorder>
|
||||
No exports found. Click the button above to start a new export.
|
||||
</Paper>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={500} type='auto'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>ID</Table.Th>
|
||||
<Table.Th>Started On</Table.Th>
|
||||
<Table.Th>Files</Table.Th>
|
||||
<Table.Th>Size</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{isLoading && <Table.Tr>Loading...</Table.Tr>}
|
||||
{data?.map((exportDb) => (
|
||||
<Table.Tr key={exportDb.id}>
|
||||
<Table.Td>{exportDb.id}</Table.Td>
|
||||
<Table.Td>{new Date(exportDb.createdAt).toLocaleString()}</Table.Td>
|
||||
<Table.Td>{exportDb.files}</Table.Td>
|
||||
<Table.Td>{exportDb.completed ? bytes(Number(exportDb.size)) : ''}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group>
|
||||
<ActionIcon onClick={() => handleDelete(exportDb.id)}>
|
||||
<IconTrashFilled size='1rem' />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
target='_blank'
|
||||
to={`/api/user/export?id=${exportDb.id}`}
|
||||
disabled={!exportDb.completed}
|
||||
>
|
||||
<IconDownload size='1rem' />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Paper withBorder p={0} mt='sm'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>ID</Table.Th>
|
||||
<Table.Th>Started</Table.Th>
|
||||
<Table.Th>Files</Table.Th>
|
||||
<Table.Th>Size</Table.Th>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{isLoading && <Table.Tr>Loading...</Table.Tr>}
|
||||
{data?.map((exportDb) => (
|
||||
<Table.Tr key={exportDb.id}>
|
||||
<Table.Td maw={140}>
|
||||
<Tooltip
|
||||
label={`${exportDb.id} is ${exportDb.completed ? 'completed' : 'in progress'}`}
|
||||
>
|
||||
<Text
|
||||
style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}
|
||||
c={exportDb.completed ? 'green' : 'dimmed'}
|
||||
>
|
||||
{exportDb.id}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RelativeDate date={new Date(exportDb.createdAt)} />
|
||||
</Table.Td>
|
||||
<Table.Td>{exportDb.files}</Table.Td>
|
||||
<Table.Td>{exportDb.completed ? bytes(Number(exportDb.size)) : ''}</Table.Td>
|
||||
<Table.Td w={95}>
|
||||
<Group>
|
||||
<ActionIcon onClick={() => handleDelete(exportDb.id)}>
|
||||
<IconTrashFilled size='1rem' />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
target='_blank'
|
||||
to={`/api/user/export?id=${exportDb.id}`}
|
||||
disabled={!exportDb.completed}
|
||||
>
|
||||
<IconDownload size='1rem' />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
ColorInput,
|
||||
Divider,
|
||||
@@ -97,7 +98,10 @@ export default function SettingsFileView() {
|
||||
<Paper withBorder p='sm'>
|
||||
<Title order={2}>Viewing Files</Title>
|
||||
<Text c='dimmed' mt='xs'>
|
||||
All text fields support using variables.
|
||||
All text fields support using{' '}
|
||||
<Anchor target='_blank' href='https://zipline.diced.sh/docs/guides/variables/'>
|
||||
variables.
|
||||
</Anchor>
|
||||
</Text>
|
||||
<Stack gap='sm' mt='xs'>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useConfig } from '@/components/ConfigProvider';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
@@ -24,11 +25,14 @@ import {
|
||||
IconUser,
|
||||
IconUserCancel,
|
||||
} from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { lazy, useEffect, useState } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
const SettingsAvatar = lazy(() => import('./SettingsAvatar'));
|
||||
|
||||
export default function SettingsUser() {
|
||||
const config = useConfig();
|
||||
const [user, setUser] = useUserStore(useShallow((state) => [state.user, state.setUser]));
|
||||
|
||||
const [tokenShown, setTokenShown] = useState(false);
|
||||
@@ -93,7 +97,7 @@ export default function SettingsUser() {
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm'>
|
||||
<Title order={2}>User Info</Title>
|
||||
<Title order={2}>User</Title>
|
||||
<Text c='dimmed' size='sm' mb='sm'>
|
||||
{user?.id}
|
||||
</Text>
|
||||
|
||||
@@ -31,12 +31,7 @@ export default function UserCard({ user }: { user: User }) {
|
||||
{user.username[0].toUpperCase()}
|
||||
</Avatar>
|
||||
|
||||
<Stack gap={1}>
|
||||
<Text fw={400}>{user.username}</Text>
|
||||
<Text size='xs' c='dimmed'>
|
||||
{user.id}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Text fw={400}>{user.username}</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap='xs'>
|
||||
@@ -79,6 +74,9 @@ export default function UserCard({ user }: { user: User }) {
|
||||
|
||||
<Card.Section inheritPadding py='xs'>
|
||||
<Stack gap={1}>
|
||||
<Text size='xs' c='dimmed'>
|
||||
<b>Id:</b> {user.id}
|
||||
</Text>
|
||||
<Text size='xs' c='dimmed'>
|
||||
<b>Role:</b> {roleName(user.role)}
|
||||
</Text>
|
||||
|
||||
@@ -135,11 +135,11 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
server.get<{ Params: { id: string } }>('/view/:id', async (req, res) => {
|
||||
server.get<{ Params: { id: string } }>('/view/:id', async (_req, res) => {
|
||||
return res.ssr('view');
|
||||
});
|
||||
|
||||
server.get<{ Params: { id: string } }>('/view/url/:id', async (req, res) => {
|
||||
server.get<{ Params: { id: string } }>('/view/url/:id', async (_req, res) => {
|
||||
return res.ssr('view-url');
|
||||
});
|
||||
|
||||
@@ -174,9 +174,10 @@ async function main() {
|
||||
server.serveIndex('/dashboard*');
|
||||
server.serveIndex('/auth*');
|
||||
server.serveIndex('/folder*');
|
||||
server.get('/', (_, res) => res.redirect('/dashboard', 301));
|
||||
}
|
||||
|
||||
server.get('/', (_, res) => res.redirect('/dashboard', 301));
|
||||
|
||||
server.setNotFoundHandler((req, res) => {
|
||||
if (req.url.startsWith('/api/')) {
|
||||
return res.status(404).send({
|
||||
|
||||
@@ -9,6 +9,7 @@ import { readFile } from 'fs/promises';
|
||||
export type ApiServerPublicResponse = {
|
||||
oauth: {
|
||||
bypassLocalLogin: boolean;
|
||||
loginOnly: boolean;
|
||||
};
|
||||
oauthEnabled: {
|
||||
discord: boolean;
|
||||
@@ -50,6 +51,7 @@ export default fastifyPlugin(
|
||||
const response: ApiServerPublicResponse = {
|
||||
oauth: {
|
||||
bypassLocalLogin: config.oauth.bypassLocalLogin,
|
||||
loginOnly: config.oauth.loginOnly,
|
||||
},
|
||||
oauthEnabled: enabled(config),
|
||||
website: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { config, reloadSettings } from '@/lib/config';
|
||||
import { reloadSettings } from '@/lib/config';
|
||||
import type { readDatabaseSettings } from '@/lib/config/read/db';
|
||||
import { safeConfig } from '@/lib/config/safe';
|
||||
import { prisma } from '@/lib/db';
|
||||
@@ -10,10 +10,9 @@ import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { statSync } from 'fs';
|
||||
import { readFile } from 'fs/promises';
|
||||
import ms, { StringValue } from 'ms';
|
||||
import { cpus } from 'os';
|
||||
import { join, resolve } from 'path';
|
||||
import { resolve } from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
type Settings = Awaited<ReturnType<typeof readDatabaseSettings>>;
|
||||
@@ -71,20 +70,6 @@ const logger = log('api').c('server').c('settings');
|
||||
export const PATH = '/api/server/settings';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.get(PATH + '/web', { preHandler: [userMiddleware] }, async (_, res) => {
|
||||
const webConfig = safeConfig(config);
|
||||
|
||||
const codeJson = await readFile(join(process.cwd(), 'code.json'));
|
||||
const codeMap = JSON.parse(codeJson.toString());
|
||||
|
||||
const data: ApiServerSettingsWebResponse = {
|
||||
config: webConfig,
|
||||
codeMap: codeMap,
|
||||
};
|
||||
|
||||
return res.send(data);
|
||||
});
|
||||
|
||||
server.get<{ Body: Body }>(
|
||||
PATH,
|
||||
{
|
||||
44
src/server/routes/api/server/settings/web.ts
Normal file
44
src/server/routes/api/server/settings/web.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { safeConfig } from '@/lib/config/safe';
|
||||
import { log } from '@/lib/logger';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
export type ApiServerSettingsWebResponse = {
|
||||
config: ReturnType<typeof safeConfig>;
|
||||
codeMap: { ext: string; mime: string; name: string }[];
|
||||
};
|
||||
|
||||
const logger = log('api').c('server').c('settings').c('web');
|
||||
|
||||
const codeJsonPath = join(process.cwd(), 'code.json');
|
||||
let codeMap: ApiServerSettingsWebResponse['codeMap'] = [];
|
||||
|
||||
export const PATH = '/api/server/settings/web';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.get(PATH, { preHandler: [userMiddleware] }, async (_, res) => {
|
||||
const webConfig = safeConfig(config);
|
||||
|
||||
if (codeMap.length === 0) {
|
||||
try {
|
||||
const codeJson = await readFile(codeJsonPath, 'utf8');
|
||||
codeMap = JSON.parse(codeJson);
|
||||
} catch (error) {
|
||||
logger.error('failed to read code.json', { error });
|
||||
codeMap = [];
|
||||
}
|
||||
}
|
||||
|
||||
return res.send({
|
||||
config: webConfig,
|
||||
codeMap: codeMap,
|
||||
} satisfies ApiServerSettingsWebResponse);
|
||||
});
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
@@ -15,7 +15,7 @@
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*", "./generated/*"]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["vite-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
|
||||
Reference in New Issue
Block a user