feat: small ui improvements

This commit is contained in:
diced
2025-08-12 22:15:04 -07:00
parent 9c473ae30d
commit ca149f920b
14 changed files with 203 additions and 114 deletions

View File

@@ -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 }} />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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.

View File

@@ -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>

View File

@@ -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)}>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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({

View File

@@ -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: {

View File

@@ -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,
{

View 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 },
);

View File

@@ -15,7 +15,7 @@
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": ["./src/*", "./generated/*"]
"@/*": ["./src/*"]
}
},
"include": ["vite-env.d.ts", "**/*.ts", "**/*.tsx"],