feat: all functionality added + client/ -> src/client/

This commit is contained in:
diced
2025-08-06 21:52:24 -07:00
parent e5a01c5034
commit af3f23a241
44 changed files with 649 additions and 224 deletions

View File

@@ -1,14 +0,0 @@
import Markdown from '@/components/render/Markdown';
import { Response } from '@/lib/api/response';
import useSWR from 'swr';
export default function Tos() {
const { data: config } = useSWR<Response['/api/server/public']>('/api/server/public', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
});
return <Markdown md={config?.tos || ''} />;
}

View File

@@ -1,105 +0,0 @@
import Layout from '@/components/Layout';
import DashboardHome from '@/components/pages/dashboard';
import DashboardFiles from '@/components/pages/files';
import DashboardFolders from '@/components/pages/folders';
import DashboardInvites from '@/components/pages/invites';
import DashboardMetrics from '@/components/pages/metrics';
import DashboardServerSettings from '@/components/pages/serverSettings';
import DashboardSettings from '@/components/pages/settings';
import UploadFile from '@/components/pages/upload/File';
import UploadText from '@/components/pages/upload/Text';
import DashboardURLs from '@/components/pages/urls';
import DashboardUsers from '@/components/pages/users';
import { Response as ApiResponse } from '@/lib/api/response';
import { createBrowserRouter } from 'react-router-dom';
import Login from './pages/auth/login';
import Logout from './pages/auth/logout';
import Register from './pages/auth/register';
import Tos from './pages/auth/tos';
import ViewFolderId from './pages/folder/[id]';
import ViewFolderIdUpload from './pages/folder/[id]/upload';
import Root from './Root';
export async function dashboardLoader(): Promise<ApiResponse['/api/server/settings/web']> {
const res = await fetch('/api/server/settings/web');
if (!res.ok) {
throw new Response('Failed to load settings', { status: res.status });
}
const data = await res.json();
console.log('Loaded settings:', data);
return data;
}
export const router = createBrowserRouter([
{
Component: () => <Root />,
path: '/',
children: [
{
path: '/auth',
children: [
{ path: 'login', Component: Login },
{ path: 'logout', Component: Logout },
{ path: 'register', Component: Register },
{ path: 'tos', Component: Tos },
],
},
{
path: '/dashboard',
Component: Layout,
loader: dashboardLoader,
children: [
{ index: true, Component: DashboardHome },
{ path: 'metrics', Component: DashboardMetrics },
{ path: 'settings', Component: DashboardSettings },
{ path: 'files', Component: DashboardFiles },
{ path: 'folders', Component: DashboardFolders },
{ path: 'urls', Component: DashboardURLs },
{
path: 'upload',
children: [
{ path: 'file', Component: UploadFile },
{ path: 'text', Component: UploadText },
],
},
{
path: 'admin',
children: [
{ path: 'invites', Component: DashboardInvites },
{ path: 'users', Component: DashboardUsers },
{ path: 'settings', Component: DashboardServerSettings },
],
},
],
},
{
path: 'folder/:id',
loader: async ({ params }) => {
const res = await fetch(`/api/server/folder/${params.id}`);
if (!res.ok) {
throw new Response('Folder not found', { status: 404 });
}
return {
folder: await res.json(),
};
},
Component: ViewFolderId,
},
{
path: 'folder/:id/upload',
loader: async ({ params }) => {
const res = await fetch(`/api/server/folder/${params.id}?upload=true`);
if (!res.ok) {
throw new Response('Folder not found', { status: 404 });
}
return {
folder: await res.json(),
};
},
Component: ViewFolderIdUpload,
},
],
},
]);

View File

@@ -4,12 +4,13 @@
"license": "MIT",
"version": "4.2.1",
"scripts": {
"build": "pnpm run --stream build:prisma && pnpm run --stream build:server && pnpm run --stream build:client",
"build": "pnpm run --stream build:types && pnpm run --stream build:prisma && pnpm run --stream build:server && pnpm run --stream build:client",
"build:types": "tsc",
"build:prisma": "prisma generate --no-hints",
"build:server": "tsup",
"build:client": "vite build && pnpm run --stream \"/^build-ssr:.*/\"",
"build-ssr:view": "vite build --ssr ssr-view/server.tsx -m ssr-view --outDir ../build/ssr --emptyOutDir=false",
"build-ssr:view-url": "vite build --ssr ssr-view-url/server.tsx -m ssr-view-url --outDir ../build/ssr --emptyOutDir=false",
"build-ssr:view": "vite build --ssr ssr-view/server.tsx -m ssr-view --outDir ../../build/ssr --emptyOutDir=false",
"build-ssr:view-url": "vite build --ssr ssr-view-url/server.tsx -m ssr-view-url --outDir ../../build/ssr --emptyOutDir=false",
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:nd": "cross-env NODE_ENV=development tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:inspector": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",

View File

@@ -0,0 +1,11 @@
import GenericError from './GenericError';
export default function DashboardErrorBoundary(props: Record<string, any>) {
return (
<GenericError
title='Dashboard Client Error'
message='Something went wrong while loading the dashboard. Please try again later, or report this issue if it persists.'
details={props}
/>
);
}

View File

@@ -0,0 +1,27 @@
import { Container, Paper, Stack, Text, Title } from '@mantine/core';
export default function GenericError({
title,
message,
details,
}: {
title?: string;
message?: string;
details?: Record<string, any>;
}) {
return (
<Container my='lg'>
<Stack gap='xs'>
<Title order={5}>{title || 'An error occurred'}</Title>
<Text c='dimmed'>
{message || 'Something went wrong. Please try again later, or report this issue if it persists.'}
</Text>
{details && (
<Paper withBorder px={3} py={3}>
<pre style={{ margin: 0 }}>{JSON.stringify(details, null, 2)}</pre>
</Paper>
)}
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,11 @@
import GenericError from './GenericError';
export default function RootErrorBoundary(props: Record<string, any>) {
return (
<GenericError
title='Dashboard Client Error'
message='Something went wrong while loading the dashboard. Please try again later, or report this issue if it persists.'
details={props}
/>
);
}

View File

@@ -1,5 +1,4 @@
import { StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { router } from './routes';
@@ -9,9 +8,9 @@ import '@mantine/dates/styles.css';
import '@mantine/dropzone/styles.css';
import '@mantine/notifications/styles.css';
import 'mantine-datatable/styles.css';
import { createRoot } from 'react-dom/client';
hydrateRoot(
document.getElementById('root')!,
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,

26
src/client/pages/404.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { Button, Center, Stack, Text, Title } from '@mantine/core';
import { IconArrowLeft } from '@tabler/icons-react';
import { Link } from 'react-router-dom';
export default function FourOhFour() {
return (
<Center h='100vh'>
<Stack>
<Title order={1}>404</Title>
<Text c='dimmed' mt='-md'>
Page not found
</Text>
<Button
component={Link}
to='/auth/login'
color='blue'
fullWidth
leftSection={<IconArrowLeft size='1rem' />}
>
Go home
</Button>
</Stack>
</Center>
);
}

View File

@@ -32,15 +32,17 @@ import {
IconX,
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Link, redirect, useLocation, useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import GenericError from '../../error/GenericError';
export default function Login() {
const navigate = useNavigate();
const location = useLocation();
const query = new URLSearchParams(location.search);
const { user, mutate } = useLogin();
const navigate = useNavigate();
const {
data: config,
error: configError,
@@ -147,7 +149,7 @@ export default function Login() {
useEffect(() => {
if (user) {
navigate('/dashboard', { replace: true });
navigate('/dashboard');
}
}, [user]);
@@ -158,7 +160,7 @@ export default function Login() {
);
if (provider) {
navigate(`/api/auth/oauth/${provider.toLowerCase()}`, { replace: true });
redirect(`/api/auth/oauth/${provider.toLowerCase()}`);
}
}
}, [willRedirect, config]);
@@ -178,25 +180,22 @@ export default function Login() {
}
}, [passkeyErrored]);
if (configLoading) {
return <LoadingOverlay visible />;
}
useEffect(() => {
if (config?.firstSetup) navigate('/auth/setup');
}, [config]);
if (configError) {
return (
<Center h='100vh'>
<Text c='red'>Failed to load configuration. Please try again later.</Text>
</Center>
);
}
if (configLoading) return <LoadingOverlay visible />;
if (!config) {
if (configError)
return (
<Center h='100vh'>
<Text c='red'>Configuration is not available. Please try again later.</Text>
</Center>
<GenericError
title='Error loading configuration'
message='Could not load server configuration...'
details={configError}
/>
);
}
if (!config) return <LoadingOverlay visible />;
return (
<>

View File

@@ -1,12 +1,12 @@
import { useUserStore } from '@/lib/store/user';
import { LoadingOverlay } from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { mutate } from 'swr';
export default function Logout() {
const navigate = useNavigate();
const setUser = useUserStore((state) => state.setUser);
const navigate = useNavigate();
useEffect(() => {
(async () => {
@@ -19,6 +19,8 @@ export default function Logout() {
setUser(null);
mutate('/api/user', null);
navigate('/auth/login');
} else {
navigate('/dashboard');
}
} else {
navigate('/dashboard');

View File

@@ -18,12 +18,13 @@ import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { IconLogin, IconPlus, IconUserPlus, IconX } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Link, redirect, useLocation, useNavigate } from 'react-router-dom';
import useSWR, { mutate } from 'swr';
import GenericError from '../../error/GenericError';
export default function Register() {
const navigate = useNavigate();
const location = useLocation();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [invite, setInvite] = useState<any>(null);
@@ -53,19 +54,17 @@ export default function Register() {
},
});
// Check if already logged in
useEffect(() => {
(async () => {
const res = await fetch('/api/user');
if (res.ok) {
navigate('/auth/login');
redirect('/dashboard');
} else {
setLoading(false);
}
})();
}, []);
// Fetch invite if present
useEffect(() => {
(async () => {
if (!code) return;
@@ -75,13 +74,13 @@ export default function Register() {
const json = await res.json();
setInvite(json.invite);
} else {
navigate('/auth/login');
redirect('/auth/login');
}
})();
}, [code]);
useEffect(() => {
if (!code && !config?.features.userRegistration) {
if (!config?.features.userRegistration) {
navigate('/auth/login');
}
}, [code, config]);
@@ -120,24 +119,19 @@ export default function Register() {
});
mutate('/api/user');
navigate('/dashboard');
redirect('/dashboard');
}
};
if (loading) return <LoadingOverlay visible />;
if (loading || configLoading) return <LoadingOverlay visible />;
if (!config || configError || configLoading) {
if (!config || configError) {
return (
<Center h='100vh'>
<Paper p='xl' shadow='xl' withBorder>
<Title order={2} ta='center'>
Failed to load configuration
</Title>
<Text ta='center' size='sm' c='dimmed'>
Please try again later.
</Text>
</Paper>
</Center>
<GenericError
title='Error loading configuration'
message='Could not load server configuration...'
details={configError}
/>
);
}

View File

@@ -0,0 +1,232 @@
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import {
Anchor,
Button,
Code,
Group,
Paper,
PasswordInput,
SimpleGrid,
Stack,
Stepper,
Text,
TextInput,
Title,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { IconArrowBackUp, IconArrowForwardUp, IconCheck, IconX } from '@tabler/icons-react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { mutate } from 'swr';
function LinkToDoc({ href, title, children }: { href: string; title: string; children: React.ReactNode }) {
return (
<Text>
<Anchor href={href} target='_blank' rel='noopener noreferrer'>
{title}
</Anchor>{' '}
{children}
</Text>
);
}
export default function Setup() {
const navigate = useNavigate();
const [active, setActive] = useState(0);
const nextStep = () => setActive((current) => (current < 3 ? current + 1 : current));
const prevStep = () => setActive((current) => (current > 0 ? current - 1 : current));
const [loading, setLoading] = useState(false);
const form = useForm({
initialValues: {
username: '',
password: '',
},
validate: {
username: (value) => (value.length < 1 ? 'Username is required' : null),
password: (value) => (value.length < 1 ? 'Password is required' : null),
},
});
const onSubmit = async (values: typeof form.values) => {
setLoading(true);
const { error } = await fetchApi('/api/setup', 'POST', {
username: values.username,
password: values.password,
});
if (error) {
notifications.show({
title: 'Error',
message: error.error,
color: 'red',
icon: <IconX size='1rem' />,
});
setLoading(false);
setActive(2);
} else {
notifications.show({
title: 'Setup complete!',
message: 'Logging in to new user...',
color: 'green',
loading: true,
});
const { data, error } = await fetchApi<Response['/api/auth/login']>('/api/auth/login', 'POST', {
username: values.username,
password: values.password,
});
if (error) {
notifications.show({
title: 'Error',
message: error.error,
color: 'red',
icon: <IconX size='1rem' />,
});
setLoading(false);
setActive(2);
} else {
mutate('/api/user', data as Response['/api/user']);
navigate('/dashboard');
}
}
};
return (
<>
<Paper withBorder p='xs' m='sm'>
<Stepper active={active} onStepClick={setActive} m='md'>
<Stepper.Step label='Welcome!' description='Setup Zipline'>
<Title>Welcome to Zipline!</Title>
<SimpleGrid spacing='md' cols={{ base: 1, sm: 1 }}>
<Paper withBorder p='sm' my='sm' h='100%'>
<Title order={2}>Documentation</Title>
<Text>Here are a couple of useful documentation links to get you started with Zipline:</Text>
<Stack mt='xs'>
<LinkToDoc href='https://zipline.diced.sh/docs/config' title='Configuration'>
Configuring Zipline to your needs
</LinkToDoc>
<LinkToDoc href='https://zipline.diced.sh/docs/migrate' title='Migrate from v3 to v4'>
Upgrading from a previous version of Zipline
</LinkToDoc>
</Stack>
</Paper>
<Paper withBorder p='sm' my='sm' h='100%'>
<Title order={2}>Configuration</Title>
<Text>
Most of Zipline&apos;s configuration is now managed through the dashboard. Once you login as
a super-admin, you can click on your username in the top right corner and select
&quot;Server Settings&quot; to configure your instance. The only exception to this is a few
sensitive environment variables that must be set in order for Zipline to run. To change
this, depending on the setup, you can either edit the <Code>.env</Code> or{' '}
<Code>docker-compose.yml</Code> file.
</Text>
<Text>
To see all of the available environment variables, please refer to the documentation{' '}
<Anchor
href='https://zipline.diced.sh/docs/config'
target='_blank'
rel='noopener noreferrer'
>
here.
</Anchor>
</Text>
</Paper>
</SimpleGrid>
<Button
mt='xl'
fullWidth
rightSection={<IconArrowForwardUp size='1.25rem' />}
size='lg'
variant='default'
onClick={nextStep}
>
Continue
</Button>
</Stepper.Step>
<Stepper.Step label='Create user' description='Create a super-admin account'>
<Stack gap='lg'>
<Title order={2}>Create your super-admin account</Title>
<TextInput
label='Username'
placeholder='Enter a username...'
{...form.getInputProps('username')}
/>
<PasswordInput
label='Password'
placeholder='Enter a password...'
{...form.getInputProps('password')}
/>
</Stack>
<Group justify='space-between' my='lg'>
<Button
leftSection={<IconArrowBackUp size='1.25rem' />}
size='lg'
variant='default'
onClick={prevStep}
>
Back
</Button>
<Button
rightSection={<IconArrowForwardUp size='1.25rem' />}
size='lg'
variant='default'
onClick={nextStep}
disabled={!form.isValid()}
>
Continue
</Button>
</Group>
</Stepper.Step>
<Stepper.Completed>
<Title order={2}>Setup complete!</Title>
<Text>
Clicking &quot;Finish&quot; below will create your super-admin account and log you in. You will
be redirected to the dashboard shortly after that.
</Text>
<Group justify='space-between' my='lg'>
<Button
leftSection={<IconArrowBackUp size='1.25rem' />}
size='lg'
variant='default'
onClick={prevStep}
loading={loading}
>
Back
</Button>
<Button
rightSection={<IconCheck size='1.25rem' />}
size='lg'
variant='default'
loading={loading}
onClick={() => form.onSubmit(onSubmit)()}
>
Finish
</Button>
</Group>
</Stepper.Completed>
</Stepper>
</Paper>
</>
);
}

View File

@@ -0,0 +1,36 @@
import Markdown from '@/components/render/Markdown';
import { Response } from '@/lib/api/response';
import { Container, LoadingOverlay } from '@mantine/core';
import useSWR from 'swr';
import GenericError from '../../error/GenericError';
export default function Tos() {
const {
data: config,
error,
isLoading,
} = useSWR<Response['/api/server/public']>('/api/server/public', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
});
if (isLoading) return <LoadingOverlay visible />;
if (error) {
return (
<GenericError
title='Error loading TOS'
message='Could not load Terms of Service file...'
details={error}
/>
);
}
return (
<Container my='lg'>
<Markdown md={config?.tos || ''} />
</Container>
);
}

View File

@@ -10,7 +10,7 @@ export default function ViewFolderId() {
return (
<>
<Container>
<Container my='lg'>
<Group>
<Title order={1}>{folder.name}</Title>

View File

@@ -20,7 +20,7 @@ export default function ViewFolderIdUpload() {
return (
<>
<Container>
<Container my='lg'>
<ConfigProvider data={{ config: config as unknown as SafeConfig, codeMap: [] }}>
<UploadFile title={`Upload files to ${folder.name}`} folder={folder.id} />
<Center>

View File

@@ -24,7 +24,7 @@ import { IconDownload, IconExternalLink, IconInfoCircleFilled } from '@tabler/ic
import * as sanitize from 'isomorphic-dompurify';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useSsrData } from '../../../src/components/ZiplineSSRProvider';
import { useSsrData } from '../../../components/ZiplineSSRProvider';
import { getFile } from '../../ssr-view/server';
type SsrData = {

161
src/client/routes.tsx Normal file
View File

@@ -0,0 +1,161 @@
import Layout from '@/components/Layout';
import DashboardHome from '@/components/pages/dashboard';
import DashboardFiles from '@/components/pages/files';
import DashboardFolders from '@/components/pages/folders';
import DashboardInvites from '@/components/pages/invites';
import DashboardMetrics from '@/components/pages/metrics';
import DashboardServerSettings from '@/components/pages/serverSettings';
import DashboardSettings from '@/components/pages/settings';
import UploadFile from '@/components/pages/upload/File';
import UploadText from '@/components/pages/upload/Text';
import DashboardURLs from '@/components/pages/urls';
import DashboardUsers from '@/components/pages/users';
import ViewUserFiles from '@/components/pages/users/ViewUserFiles';
import { Response as ApiResponse } from '@/lib/api/response';
import { createBrowserRouter, redirect } from 'react-router-dom';
import FourOhFour from './pages/404';
import Login from './pages/auth/login';
import Logout from './pages/auth/logout';
import Register from './pages/auth/register';
import Tos from './pages/auth/tos';
import ViewFolderId from './pages/folder/[id]';
import ViewFolderIdUpload from './pages/folder/[id]/upload';
import Root from './Root';
import DashboardErrorBoundary from './error/DashboardErrorBoundary';
import RootErrorBoundary from './error/RootErrorBoundary';
import Setup from './pages/auth/setup';
export async function dashboardLoader() {
const res = await fetch('/api/server/settings/web');
if (!res.ok) {
return redirect('/auth/login');
}
const data = await res.json();
console.log('Loaded settings:', data);
return data as ApiResponse['/api/server/settings/web'];
}
export const router = createBrowserRouter([
{
Component: Root,
path: '/',
children: [
{
ErrorBoundary: RootErrorBoundary,
children: [
{ path: '*', Component: FourOhFour },
{
path: '/auth',
children: [
{ path: 'login', Component: Login },
{ path: 'logout', Component: Logout },
{ path: 'register', Component: Register },
{
path: 'setup',
Component: Setup,
loader: async () => {
const res = await fetch('/api/server/public');
if (!res.ok) {
throw new Response('Failed to fetch server settings', { status: res.status });
}
const data = await res.json();
if (!data.firstSetup) return redirect('/auth/login');
return {};
},
},
{ path: 'tos', Component: Tos },
],
},
{
path: '/dashboard',
Component: Layout,
loader: dashboardLoader,
children: [
{
ErrorBoundary: DashboardErrorBoundary,
children: [
{ index: true, Component: DashboardHome },
{ path: 'metrics', Component: DashboardMetrics },
{ path: 'settings', Component: DashboardSettings },
{ path: 'files', Component: DashboardFiles },
{ path: 'folders', Component: DashboardFolders },
{ path: 'urls', Component: DashboardURLs },
{
path: 'upload',
children: [
{ path: 'file', Component: UploadFile },
{ path: 'text', Component: UploadText },
],
},
{
path: 'admin',
children: [
{ path: 'invites', Component: DashboardInvites },
{ path: 'settings', Component: DashboardServerSettings },
{
path: 'users',
children: [
{ index: true, Component: DashboardUsers },
{
path: ':id/files',
loader: async ({ params }) => {
const res = await fetch('/api/users/' + params.id);
if (!res.ok) {
console.log("can't get user", res.status);
return redirect('/dashboard/admin/users');
}
const user = await res.json();
return { user };
},
Component: ViewUserFiles,
},
],
},
],
},
],
},
],
},
{
path: 'folder/:id',
children: [
{
index: true,
loader: async ({ params }) => {
const res = await fetch(`/api/server/folder/${params.id}`);
if (!res.ok) {
throw new Response('Folder not found', { status: 404 });
}
return {
folder: await res.json(),
};
},
Component: ViewFolderId,
},
{
path: 'upload',
loader: async ({ params }) => {
const res = await fetch(`/api/server/folder/${params.id}?upload=true`);
if (!res.ok) {
throw new Response('Folder not found', { status: 404 });
}
return {
folder: await res.json(),
};
},
Component: ViewFolderIdUpload,
},
],
},
],
},
],
},
]);

View File

@@ -20,21 +20,7 @@ const initialData = (window as any)[ZIPLINE_SSR_PROP];
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ZiplineSSRProvider ssrData={initialData}>
<RouterProvider
// router={createBrowserRouter([
// {
// path: '/view',
// Component: () => <Root />,
// children: [
// {
// path: ':id',
// Component: () => <ViewFileId />,
// },
// ],
// },
// ])}
router={router}
/>
<RouterProvider router={router} />
</ZiplineSSRProvider>
</StrictMode>,
);

View File

@@ -1,5 +1,5 @@
import { createContext, useContext } from 'react';
import { dashboardLoader } from '../../client/routes';
import { dashboardLoader } from '../client/routes';
type ConfigContextType = Awaited<ReturnType<typeof dashboardLoader>>;

View File

@@ -49,7 +49,7 @@ import { useState } from 'react';
import ConfigProvider from './ConfigProvider';
import VersionBadge from './VersionBadge';
import { Link, useLoaderData } from 'react-router-dom';
import { dashboardLoader } from '../../client/routes';
import { dashboardLoader } from '../client/routes';
type NavLinks = {
label: string;

View File

@@ -15,7 +15,6 @@ export default function DashboardHome() {
const { user } = useLogin();
const { data: recent, isLoading: recentLoading } = useSWR<Response['/api/user/recent']>('/api/user/recent');
const { data: stats, isLoading: statsLoading } = useSWR<Response['/api/user/stats']>('/api/user/stats');
return (
<>
<Title>

View File

@@ -1,3 +1,5 @@
import DashboardFile from '@/components/file/DashboardFile';
import { useQueryState } from '@/lib/hooks/useQueryState';
import {
Accordion,
Button,
@@ -11,13 +13,11 @@ import {
Title,
} from '@mantine/core';
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
import { useApiPagination } from '../useApiPagination';
import DashboardFile from '@/components/file/DashboardFile';
import { Link } from 'react-router-dom';
import { useState } from 'react';
import { useApiPagination } from '../useApiPagination';
export default function FavoriteFiles() {
const [page, setPage] = useState(1);
const [page, setPage] = useQueryState('fpage', 1);
const { data, isLoading } = useApiPagination({
page,

View File

@@ -39,11 +39,12 @@ import {
} from '@tabler/icons-react';
import { DataTable } from 'mantine-datatable';
import { useEffect, useReducer, useState } from 'react';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
import { bulkDelete, bulkFavorite } from '../bulk';
import TagPill from '../tags/TagPill';
import { useApiPagination } from '../useApiPagination';
import { Link } from 'react-router-dom';
import { useQueryState } from '@/lib/hooks/useQueryState';
type ReducerQuery = {
state: { name: string; originalName: string; type: string; tags: string; id: string };
@@ -185,8 +186,7 @@ export default function FileTable({ id }: { id?: string }) {
'/api/user/folders?noincl=true',
);
// const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [page, setPage] = useState(1);
const [page, setPage] = useQueryState('page', 1);
const [perpage, setPerpage] = useState(20);
const [sort, setSort] = useState<
| 'id'

View File

@@ -16,11 +16,12 @@ import { useEffect, useState } from 'react';
import { useApiPagination } from '../useApiPagination';
import DashboardFile from '@/components/file/DashboardFile';
import { Link } from 'react-router-dom';
import { useQueryState } from '@/lib/hooks/useQueryState';
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
export default function Files({ id }: { id?: string }) {
const [page, setPage] = useState(1);
const [page, setPage] = useQueryState('page', 1);
const [perpage, setPerpage] = useState(15);
const [cachedPages, setCachedPages] = useState(1);

View File

@@ -1,4 +1,5 @@
import DashboardFile from '@/components/file/DashboardFile';
import { useQueryState } from '@/lib/hooks/useQueryState';
import {
Accordion,
Button,
@@ -12,12 +13,11 @@ import {
Title,
} from '@mantine/core';
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
import { useApiPagination } from '../files/useApiPagination';
import { Link } from 'react-router-dom';
import { useState } from 'react';
import { useApiPagination } from '../files/useApiPagination';
export default function FavoriteFiles() {
const [page, setPage] = useState(1);
const [page, setPage] = useQueryState('fpage', 1);
const { data, isLoading } = useApiPagination({
page,
favorite: true,

View File

@@ -5,9 +5,15 @@ import { ActionIcon, Group, Title, Tooltip } from '@mantine/core';
import FileTable from '../files/views/FileTable';
import Files from '../files/views/Files';
import { IconArrowBackUp } from '@tabler/icons-react';
import { Link } from 'react-router-dom';
import { Link, useLoaderData } from 'react-router-dom';
export default function ViewFiles({ user }: { user: User }) {
export default function ViewUserFiles() {
const data = useLoaderData<{
user: User;
}>();
if (!data) return null;
const { user } = data;
if (!user) return null;
const view = useViewStore((state) => state.files);

View File

@@ -1,13 +1,12 @@
import { useEffect } from 'react';
import { redirect } from 'react-router-dom';
import useSWR from 'swr';
import type { Response } from '../api/response';
import { useUserStore } from '../store/user';
import { isAdministrator } from '../role';
import { useShallow } from 'zustand/shallow';
import { useNavigate } from 'react-router-dom';
import type { Response } from '../api/response';
import { isAdministrator } from '../role';
import { useUserStore } from '../store/user';
export default function useLogin(administratorOnly: boolean = false) {
const navigate = useNavigate();
const { data, error, isLoading, mutate } = useSWR<Response['/api/user']>('/api/user', {
fallbackData: { user: undefined },
});
@@ -18,13 +17,13 @@ export default function useLogin(administratorOnly: boolean = false) {
if (data?.user) {
setUser(data.user);
} else if (error) {
navigate('/auth/login');
redirect('/auth/login');
}
}, [data, error]);
useEffect(() => {
if (user && administratorOnly && !isAdministrator(user.role)) {
navigate('/dashboard');
redirect('/dashboard');
}
}, [user]);

View File

@@ -0,0 +1,37 @@
import { useSearchParams } from 'react-router-dom';
function parseValue<T>(value: string | null, defaultValue: T): T {
if (value === null) return defaultValue;
if (typeof defaultValue === 'number') {
const parsed = Number(value);
return isNaN(parsed) ? defaultValue : (parsed as T);
}
if (typeof defaultValue === 'boolean') {
return (value === 'true') as T;
}
return value as T;
}
export function useQueryState<T>(key: string, defaultValue: T): [T, (value: T | null) => void] {
const [searchParams, setSearchParams] = useSearchParams();
const rawValue = searchParams.get(key);
const value: T = parseValue(rawValue, defaultValue);
const setValue = (newValue: T | null) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (newValue === null) {
next.delete(key);
} else {
next.set(key, String(newValue));
}
return next;
});
};
return [value, setValue];
}

View File

@@ -177,6 +177,18 @@ async function main() {
server.get('/', (_, res) => res.redirect('/dashboard', 301));
}
server.setNotFoundHandler((req, res) => {
if (req.url.startsWith('/api/')) {
return res.status(404).send({
message: `Route ${req.method}:${req.url} not found`,
error: 'Not Found',
statusCode: 404,
});
} else {
return res.serveIndex();
}
});
server.setErrorHandler((error, _, res) => {
if (error.statusCode) {
res.status(error.statusCode);

View File

@@ -19,6 +19,7 @@ async function vitePlugin(fastify: FastifyInstance) {
if (MODE === 'production') {
fastify.decorate('serveIndex', route);
fastify.decorateReply('serveIndex', serveIndex);
await fastify.register(fastifyStatic, {
root: resolve('./build/client'),
@@ -61,7 +62,7 @@ async function vitePlugin(fastify: FastifyInstance) {
let render: (response: any, url: string) => Promise<ReturnType<typeof renderHtml>>;
if (MODE === 'development' && fastify.vite) {
template = await readFile(resolve(`./client/ssr-${type}/`, 'index.html'), 'utf-8');
template = await readFile(resolve(`./src/client/ssr-${type}/`, 'index.html'), 'utf-8');
template = await fastify.vite.transformIndexHtml(url, template);
render = (await fastify.vite.ssrLoadModule(`/ssr-${type}/server.tsx`)).render;
} else {
@@ -101,10 +102,13 @@ async function vitePlugin(fastify: FastifyInstance) {
});
async function handler(_: FastifyRequest, reply: FastifyReply) {
const template = await readFile(resolve('./build/client', 'index.html'), 'utf8');
reply.type('text/html').send(template);
return reply.serveIndex();
}
}
async function serveIndex(this: FastifyReply) {
return this.sendFile('index.html', resolve('./build/client'));
}
}
export default fastifyPlugin(vitePlugin, {
@@ -120,5 +124,6 @@ declare module 'fastify' {
interface FastifyReply {
ssr: (type: 'view-url' | 'view') => Promise<void>;
serveIndex: () => Promise<void>;
}
}

View File

@@ -1,5 +1,6 @@
import { config } from '@/lib/config';
import { Config } from '@/lib/config/validate';
import { getZipline } from '@/lib/db/models/zipline';
import { log } from '@/lib/logger';
import enabled from '@/lib/oauth/enabled';
import fastifyPlugin from 'fastify-plugin';
@@ -33,6 +34,7 @@ export type ApiServerPublicResponse = {
maxFileSize: string;
};
chunks: Config['chunks'];
firstSetup: boolean;
};
const logger = log('api').c('server').c('public');
@@ -43,6 +45,8 @@ export const PATH = '/api/server/public';
export default fastifyPlugin(
(server, _, done) => {
server.get<{ Body: Body }>(PATH, async (req, res) => {
const zipline = await getZipline();
const response: ApiServerPublicResponse = {
oauth: {
bypassLocalLogin: config.oauth.bypassLocalLogin,
@@ -65,6 +69,7 @@ export default fastifyPlugin(
maxFileSize: config.files.maxFileSize,
},
chunks: config.chunks,
firstSetup: zipline.firstSetup,
};
if (config.website.tos) {

View File

@@ -18,6 +18,6 @@
"@/*": ["./src/*", "./generated/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "client/mount.js"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "uploads"]
}

View File

@@ -12,7 +12,7 @@ export default defineConfig(async (_) => {
clean: true,
sourcemap: true,
entry: await glob('./src/**/*.ts', {
ignore: ['./src/components/**/*.ts'],
ignore: ['./src/components/**/*.ts', './src/client/**/*.(ts|tsx|html)'],
}),
shims: true,
esbuildPlugins: [],

View File

@@ -7,9 +7,9 @@ export default defineConfig(({ mode }) => {
if (mode === 'development')
return {
plugins: [react()],
root: 'client',
root: './src/client',
build: {
outDir: '../build/client',
outDir: '../../build/client',
rollupOptions: {
output: {
format: 'cjs',
@@ -28,18 +28,15 @@ export default defineConfig(({ mode }) => {
return {
plugins: [react()],
root: 'client',
root: './src/client',
build: {
outDir: '../build/client',
outDir: '../../build/client',
emptyOutDir: true,
sourcemap: true,
minify: false,
rollupOptions: {
input: {
main: path.resolve(__dirname, 'client/index.html'),
'ssr-view': path.resolve(__dirname, 'client/ssr-view/index.html'),
'ssr-view-url': path.resolve(__dirname, 'client/ssr-view-url/index.html'),
main: path.resolve(__dirname, 'src/client/index.html'),
'ssr-view': path.resolve(__dirname, 'src/client/ssr-view/index.html'),
'ssr-view-url': path.resolve(__dirname, 'src/client/ssr-view-url/index.html'),
},
...(mode.startsWith('ssr') && {
output: {
@@ -57,5 +54,3 @@ export default defineConfig(({ mode }) => {
},
};
});
console.log('Vite configuration loaded');