feat: remove next.js in favor of client-side only (#857)

* feat: start removing next.js

* feat: working ssr + dev + prod env

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

* fix: build process

* fix: caching on pnpm action

* fix: ignores + cache action

* fix: docker + exdev error

* fix: generate prisma before types

* fix: remove node@20 from actions

* feat: dynamic import optimizations + titled pages

* fix: removed unused vars

* feat: small ui fixes and improvements

* feat: small ui improvements

* fix: linting error

* fix: regex when adding domains
This commit is contained in:
dicedtomato
2025-08-14 12:13:54 -07:00
committed by GitHub
parent 71dbbb584a
commit ae7b4dacf1
190 changed files with 4035 additions and 3707 deletions

View File

@@ -1,5 +1,4 @@
.github
.next
build
node_modules
uploads*

View File

@@ -17,10 +17,8 @@ body:
label: Version
description: What version (or docker image) of Zipline are you using?
options:
- Latest v4 release (ghcr.io/diced/zipline or ghcr.io/diced/zipline:latest)
- Latest v4 commit (ghcr.io/diced/zipline:trunk)
- Latest v3 release (ghcr.io/diced/zipline:v3)
- Latest v3 commit (ghcr.io/diced/zipline:v3-trunk)
- Latest release (ghcr.io/diced/zipline or ghcr.io/diced/zipline:latest)
- Latest commit (ghcr.io/diced/zipline:trunk)
- other (provide version in additional info)
validations:
required: true
@@ -33,13 +31,14 @@ body:
- Firefox
- Chromium-based (Chrome, Edge, Brave, Opera, mobile chrome/chromium based, etc)
- Safari
- Chromium-based Mobile (Chrome, Edge, Brave, Android WebView, etc)
- Firefox Mobile
- Safari Mobile
- type: textarea
id: zipline-logs
attributes:
label: Zipline Logs
description: Please copy and paste any relevant log output. Not seeing anything interesting? Try adding the `DEBUG=zipline` (v4) or `DEBUG=true` (v3) environment variable to see more logs, make sure to review the output and remove any sensitive information as it can be VERY verbose at times.
description: Please copy and paste any relevant log output. Not seeing anything interesting? Try adding the `DEBUG=zipline` (v4) environment variable to see more logs, make sure to review the output and remove any sensitive information as it can be VERY verbose at times.
render: shell
- type: textarea
id: browser-logs

View File

@@ -11,7 +11,7 @@ jobs:
build:
strategy:
matrix:
node: [20.x, 22.x, 24.x]
node: [22.x, 24.x]
arch: [amd64, arm64]
runs-on: ubuntu-24.04${{ matrix.arch == 'arm64' && '-arm' || '' }}
@@ -37,16 +37,23 @@ jobs:
with:
path: |
${{ steps.pnpm-cache.outputs.store_path }}
${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-pnpm-next-store-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
key: ${{ runner.os }}-${{ matrix.arch }}-${{ matrix.node }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-pnpm-next-store-${{ hashFiles('**/package-lock.json') }}-
${{ runner.os }}-${{ matrix.arch }}-${{ matrix.node }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}-
- name: Install
run: pnpm install
- name: Lint
run: pnpm lint
- name: Generate Prisma
run: pnpm build:prisma
- name: Type Check
run: pnpm build:types
- name: Build
env:
ZIPLINE_BUILD: 'true'
NEXT_TELEMETRY_DISABLED: '1'
run: pnpm build
run: pnpm build:skip

10
.gitignore vendored
View File

@@ -13,17 +13,14 @@
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
build/
# misc
.DS_Store
*.pem
.idea
.vscode
# debug
npm-debug.log*
@@ -38,7 +35,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# eslint
.eslintcache
@@ -52,4 +48,4 @@ next-env.d.ts
uploads*/
*.crt
*.key
generated
src/prisma

View File

@@ -20,11 +20,17 @@ FROM base AS builder
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
COPY src ./src
COPY next.config.js ./next.config.js
COPY .gitignore ./.gitignore
COPY postcss.config.cjs ./postcss.config.cjs
COPY prettier.config.cjs ./prettier.config.cjs
COPY eslint.config.mjs ./eslint.config.mjs
COPY vite.config.ts ./vite.config.ts
COPY tsup.config.ts ./tsup.config.ts
COPY tsconfig.json ./tsconfig.json
COPY mimes.json ./mimes.json
COPY code.json ./code.json
COPY vite-env.d.ts ./vite-env.d.ts
ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production
@@ -36,12 +42,9 @@ FROM base
COPY --from=deps /zipline/node_modules ./node_modules
COPY --from=builder /zipline/build ./build
COPY --from=builder /zipline/.next ./.next
COPY --from=builder /zipline/mimes.json ./mimes.json
COPY --from=builder /zipline/code.json ./code.json
COPY --from=builder /zipline/generated ./generated
RUN pnpm build:prisma

View File

@@ -2,9 +2,9 @@ import unusedImports from 'eslint-plugin-unused-imports';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-plugin-prettier';
import prettierConfig from 'eslint-config-prettier';
import nextConfig from '@next/eslint-plugin-next';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import reactPlugin from 'eslint-plugin-react';
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -23,7 +23,13 @@ const gitignorePatterns = gitignoreContent
export default tseslint.config(
{ ignores: gitignorePatterns },
...tseslint.configs.recommended,
{
extends: [
tseslint.configs.recommended,
reactHooksPlugin.configs['recommended-latest'],
reactRefreshPlugin.configs.vite,
],
},
{
files: ['**/*.{js,mjs,cjs,ts,tsx}'],
@@ -39,19 +45,12 @@ export default tseslint.config(
plugins: {
'unused-imports': unusedImports,
prettier: prettier,
'@next/next': nextConfig,
'react-hooks': reactHooksPlugin,
react: reactPlugin,
'jsx-a11y': jsxA11yPlugin,
},
rules: {
...reactPlugin.configs.recommended.rules,
...reactHooksPlugin.configs.recommended.rules,
...nextConfig.configs.recommended.rules,
...nextConfig.configs['core-web-vitals'].rules,
...prettierConfig.rules,
'prettier/prettier': [
'error',
@@ -60,7 +59,6 @@ export default tseslint.config(
fileInfoOptions: {
withNodeModules: false,
},
ignoreFileExtensions: ['pnpm-lock.yaml'],
},
],
@@ -78,6 +76,7 @@ export default tseslint.config(
'react/prop-types': 'off',
'react-hooks/rules-of-hooks': 'off',
'react-hooks/exhaustive-deps': 'off',
'react-refresh/only-export-components': 'off',
'react/jsx-uses-react': 'warn',
'react/jsx-uses-vars': 'warn',
'react/no-danger-with-children': 'warn',
@@ -110,9 +109,6 @@ export default tseslint.config(
react: {
version: 'detect',
},
next: {
rootDir: __dirname,
},
},
},
);

View File

@@ -1,24 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
rewrites: async () => [
{
source: '/invite/:code',
destination: '/auth/register?code=:code',
},
],
redirects: async () => [
{
source: '/r/:id',
destination: '/raw/:id',
permanent: true,
},
],
webpack: (config) => {
config.resolve.fallback = { worker_threads: false };
return config;
},
};
module.exports = nextConfig;

View File

@@ -4,14 +4,19 @@
"license": "MIT",
"version": "4.2.3",
"scripts": {
"build": "cross-env pnpm run --stream \"/^build:.*/\"",
"lint": "eslint .",
"build:skip": "pnpm run --stream build:prisma && pnpm run --stream build:server && pnpm run --stream build:client",
"build": "pnpm run --stream lint && 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:next": "ZIPLINE_BUILD=true next build",
"build:server": "tsup",
"dev": "cross-env TURBOPACK=1 NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:nd": "cross-env TURBOPACK=1 NODE_ENV=development tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:inspector": "cross-env TURBOPACK=1 NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
"start": "cross-env NODE_ENV=production node --trace-warnings --require dotenv/config --enable-source-maps ./build/server",
"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",
"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",
"start": "cross-env NODE_ENV=production node --trace-warnings --require dotenv/config ./build/server",
"start:inspector": "cross-env NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
"ctl": "NODE_ENV=production node --require dotenv/config --enable-source-maps ./build/ctl",
"validate": "pnpm run --stream \"/^validate:.*/\"",
@@ -55,6 +60,7 @@
"clsx": "^2.1.1",
"colorette": "^2.0.20",
"commander": "^14.0.0",
"cookie": "^1.0.2",
"cross-env": "^10.0.0",
"dayjs": "^1.11.13",
"dotenv": "^17.2.1",
@@ -70,45 +76,45 @@
"mantine-datatable": "^8.2.0",
"ms": "^2.1.3",
"multer": "2.0.2",
"next": "^15.4.5",
"nuqs": "^2.4.3",
"otplib": "^12.0.1",
"prisma": "^6.13.0",
"qrcode": "^1.5.4",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.7.1",
"remark-gfm": "^4.0.1",
"sharp": "^0.34.3",
"swr": "^2.3.4",
"typescript-eslint": "^8.38.0",
"typescript-eslint": "^8.39.0",
"vite": "^7.1.0",
"zod": "^3.25.67",
"zustand": "^5.0.7"
},
"devDependencies": {
"@next/eslint-plugin-next": "^15.4.5",
"@types/bytes": "^3.1.5",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/katex": "^0.16.7",
"@types/ms": "^2.1.0",
"@types/multer": "^2.0.0",
"@types/node": "^24.1.0",
"@types/node": "^24.2.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.32.0",
"eslint-config-next": "^15.4.5",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"eslint-plugin-unused-imports": "^4.1.4",
"postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.6.2",
"sass": "^1.89.2",
"sass": "^1.90.0",
"tsc-alias": "^1.8.16",
"tsup": "^8.5.0",
"tsx": "^4.20.3",

1791
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
generator client {
provider = "prisma-client-js"
output = "../generated/client"
provider = "prisma-client"
output = "../src/prisma"
moduleFormat = "cjs"
previewFeatures = ["queryCompiler", "driverAdapters"]
}

47
src/client/Root.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { ModalsProvider } from '@mantine/modals';
import { Notifications } from '@mantine/notifications';
import { Outlet } from 'react-router-dom';
import { SWRConfig } from 'swr';
import ThemeProvider from '@/components/ThemeProvider';
import { type ZiplineTheme } from '@/lib/theme';
import { type Config } from '@/lib/config/validate';
export default function Root({
themes,
defaultTheme,
}: {
themes?: ZiplineTheme[];
defaultTheme?: Config['website']['theme'];
}) {
return (
<SWRConfig
value={{
fetcher: async (url: RequestInfo | URL) => {
const res = await fetch(url);
if (!res.ok) {
const json = await res.json();
throw new Error(json.message);
}
return res.json();
},
}}
>
<ThemeProvider ssrThemes={themes} ssrDefaultTheme={defaultTheme}>
<ModalsProvider
modalProps={{
overlayProps: {
blur: 6,
},
centered: true,
}}
>
<Notifications zIndex={10000000} />
<Outlet />
</ModalsProvider>
</ThemeProvider>
</SWRConfig>
);
}

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}
/>
);
}

12
src/client/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Zipline</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

18
src/client/main.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { router } from './routes';
import '@mantine/charts/styles.css';
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/dropzone/styles.css';
import '@mantine/notifications/styles.css';
import 'mantine-datatable/styles.css';
import './styles/global.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);

View File

@@ -1,6 +1,6 @@
import { Button, Center, Stack, Text, Title } from '@mantine/core';
import { IconArrowLeft } from '@tabler/icons-react';
import Link from 'next/link';
import { Link } from 'react-router-dom';
export default function FourOhFour() {
return (
@@ -11,12 +11,16 @@ export default function FourOhFour() {
Page not found
</Text>
<Button component={Link} href='/' color='blue' fullWidth leftSection={<IconArrowLeft size='1rem' />}>
<Button
component={Link}
to='/auth/login'
color='blue'
fullWidth
leftSection={<IconArrowLeft size='1rem' />}
>
Go home
</Button>
</Stack>
</Center>
);
}
FourOhFour.title = '404';

View File

@@ -1,11 +1,8 @@
import ExternalAuthButton from '@/components/pages/login/ExternalAuthButton';
import { Response } from '@/lib/api/response';
import { SafeConfig } from '@/lib/config/safe';
import { getZipline } from '@/lib/db/models/zipline';
import { fetchApi } from '@/lib/fetchApi';
import { withSafeConfig } from '@/lib/middleware/next/withSafeConfig';
import useLogin from '@/lib/hooks/useLogin';
import { authenticateWeb } from '@/lib/passkey';
import { eitherTrue } from '@/lib/primitive';
import {
Button,
Center,
@@ -34,28 +31,43 @@ import {
IconUserPlus,
IconX,
} from '@tabler/icons-react';
import { InferGetServerSidePropsType } from 'next';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { Link, redirect, useLocation, useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import GenericError from '../../error/GenericError';
import { useTitle } from '@/lib/hooks/useTitle';
export default function Login({ config }: InferGetServerSidePropsType<typeof getServerSideProps>) {
const router = useRouter();
const { data, isLoading, mutate } = useSWR<Response['/api/user']>('/api/user', {
refreshInterval: 120000,
export default function Login() {
useTitle('Login');
const location = useLocation();
const query = new URLSearchParams(location.search);
const { user, mutate } = useLogin();
const navigate = useNavigate();
const {
data: config,
error: configError,
isLoading: configLoading,
} = useSWR<Response['/api/server/public']>('/api/server/public', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
});
const showLocalLogin =
router.query.local === 'true' ||
query.get('local') === 'true' ||
!(
config.oauth.bypassLocalLogin && Object.values(config.oauthEnabled).filter((x) => x === true).length > 0
config?.oauth?.bypassLocalLogin &&
Object.values(config?.oauthEnabled ?? {}).filter((x) => x === true).length > 0
);
const willRedirect =
config.oauth.bypassLocalLogin &&
Object.values(config.oauthEnabled).filter((x) => x === true).length === 1 &&
router.query.local !== 'true';
config?.oauth?.bypassLocalLogin &&
Object.values(config?.oauthEnabled ?? {}).filter((x) => x === true).length === 1 &&
query.get('local') !== 'true';
const [totpOpen, setTotpOpen] = useState(false);
const [pinDisabled, setPinDisabled] = useState(false);
@@ -65,12 +77,6 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
const [passkeyErrored, setPasskeyErrored] = useState(false);
const [passkeyLoading, setPasskeyLoading] = useState(false);
useEffect(() => {
if (data?.user) {
router.push('/dashboard');
}
}, [data]);
const form = useForm({
initialValues: {
username: '',
@@ -123,7 +129,6 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
try {
setPasskeyLoading(true);
const res = await authenticateWeb();
const { data, error } = await fetchApi<Response['/api/auth/webauthn']>('/api/auth/webauthn', 'POST', {
auth: res.toJSON(),
});
@@ -146,16 +151,22 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
};
useEffect(() => {
if (willRedirect) {
if (user) {
navigate('/dashboard');
}
}, [user]);
useEffect(() => {
if (willRedirect && config) {
const provider = Object.keys(config.oauthEnabled).find(
(x) => config.oauthEnabled[x as keyof SafeConfig['oauthEnabled']] === true,
(x) => config.oauthEnabled[x as keyof typeof config.oauthEnabled] === true,
);
if (provider) {
router.push(`/api/auth/oauth/${provider}`);
redirect(`/api/auth/oauth/${provider.toLowerCase()}`);
}
}
}, []);
}, [willRedirect, config]);
useEffect(() => {
if (passkeyErrored) {
@@ -172,6 +183,23 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
}
}, [passkeyErrored]);
useEffect(() => {
if (config?.firstSetup) navigate('/auth/setup');
}, [config]);
if (configLoading) return <LoadingOverlay visible />;
if (configError)
return (
<GenericError
title='Error loading configuration'
message='Could not load server configuration...'
details={configError}
/>
);
if (!config) return <LoadingOverlay visible />;
return (
<>
{willRedirect && !showLocalLogin && <LoadingOverlay visible />}
@@ -255,7 +283,10 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
ta='center'
style={{
whiteSpace: 'normal',
fontSize: `clamp(20px, ${Math.max(50 - (config.website.title?.length ?? 0) / 2, 20)}px, 50px)`,
fontSize: `clamp(20px, ${Math.max(
50 - (config.website.title?.length ?? 0) / 2,
20,
)}px, 50px)`,
}}
>
<b>{config.website.title ?? 'Zipline'}</b>
@@ -263,7 +294,6 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
</div>
{showLocalLogin && (
<>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<Stack my='sm'>
<TextInput
@@ -292,18 +322,17 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
size='md'
fullWidth
type='submit'
loading={isLoading}
loading={!config}
variant={config.website.loginBackground ? 'outline' : 'filled'}
>
Login
</Button>
</Stack>
</form>
</>
)}
<Stack my='xs'>
{eitherTrue(config.features.oauthRegistration, config.features.userRegistration) && (
{(config.features.oauthRegistration || config.features.userRegistration) && (
<Divider label='or' />
)}
@@ -324,7 +353,7 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
{config.features.userRegistration && (
<Button
component={Link}
href='/auth/register'
to='/auth/register'
size='md'
fullWidth
variant='outline'
@@ -333,6 +362,7 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
Sign up
</Button>
)}
<Group grow>
{config.oauthEnabled.discord && (
<ExternalAuthButton
@@ -359,19 +389,3 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
</>
);
}
export const getServerSideProps = withSafeConfig(async () => {
const { firstSetup } = await getZipline();
if (firstSetup)
return {
redirect: {
destination: '/setup',
permanent: false,
},
};
return {};
});
Login.title = 'Login';

View File

@@ -1,35 +1,35 @@
import { useTitle } from '@/lib/hooks/useTitle';
import { useUserStore } from '@/lib/store/user';
import { LoadingOverlay } from '@mantine/core';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { mutate } from 'swr';
export default function Logout() {
const router = useRouter();
useTitle('Log out');
const setUser = useUserStore((state) => state.setUser);
const navigate = useNavigate();
useEffect(() => {
(async () => {
const userRes = await fetch('/api/user');
if (userRes.ok) {
const res = await fetch('/api/auth/logout');
if (res.ok) {
setUser(null);
mutate('/api/user', null);
await router.push('/auth/login');
navigate('/auth/login');
} else {
navigate('/dashboard');
}
} else {
await router.push('/dashboard');
navigate('/dashboard');
}
})();
}, []);
return (
<>
<LoadingOverlay visible />
</>
);
return <LoadingOverlay visible />;
}
Logout.title = 'Logout';

View File

@@ -0,0 +1,260 @@
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import {
Button,
Center,
Checkbox,
Divider,
Image,
LoadingOverlay,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
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, redirect, useLocation, useNavigate } from 'react-router-dom';
import useSWR, { mutate } from 'swr';
import GenericError from '../../error/GenericError';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Register');
const location = useLocation();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [invite, setInvite] = useState<any>(null);
const {
data: config,
error: configError,
isLoading: configLoading,
} = useSWR<Response['/api/server/public']>('/api/server/public', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
});
const code = new URLSearchParams(location.search).get('code') ?? undefined;
const form = useForm({
initialValues: {
username: '',
password: '',
tos: false,
},
validate: {
username: (value) => (value.length < 1 ? 'Username is required' : null),
password: (value) => (value.length < 1 ? 'Password is required' : null),
},
});
useEffect(() => {
(async () => {
const res = await fetch('/api/user');
if (res.ok) {
redirect('/dashboard');
} else {
setLoading(false);
}
})();
}, []);
useEffect(() => {
(async () => {
if (!code) return;
const res = await fetch(`/api/auth/invite/web?code=${code}`);
if (res.ok) {
const json = await res.json();
setInvite(json.invite);
} else {
redirect('/auth/login');
}
})();
}, [code]);
useEffect(() => {
if (!config?.features.userRegistration) {
navigate('/auth/login');
}
}, [code, config]);
const onSubmit = async (values: typeof form.values) => {
const { username, password, tos } = values;
if (tos === false && config!.website.tos) {
form.setFieldError('tos', 'You must agree to the Terms of Service to continue');
return;
}
const { data, error } = await fetchApi('/api/auth/register', 'POST', {
username,
password,
code,
});
if (error) {
if (error.error === 'Username is taken') {
form.setFieldError('username', 'Username is taken');
} else {
notifications.show({
title: 'Failed to register',
message: error.error,
color: 'red',
icon: <IconX size='1rem' />,
});
}
} else {
notifications.show({
title: 'Complete!',
message: `Your "${data?.user?.username}" account has been created.`,
color: 'green',
icon: <IconPlus size='1rem' />,
});
mutate('/api/user');
redirect('/dashboard');
}
};
if (loading || configLoading) return <LoadingOverlay visible />;
if (!config || configError) {
return (
<GenericError
title='Error loading configuration'
message='Could not load server configuration...'
details={configError}
/>
);
}
return (
<Center h='100vh'>
{config.website.loginBackground && (
<Image
src={config.website.loginBackground}
alt='Background'
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
...(config.website.loginBackgroundBlur && { filter: 'blur(10px)' }),
}}
/>
)}
<Paper
w='350px'
p='xl'
shadow='xl'
withBorder
style={{
backgroundColor: config.website.loginBackground ? 'rgba(0, 0, 0, 0)' : undefined,
backdropFilter: config.website.loginBackgroundBlur ? 'blur(35px)' : undefined,
}}
>
<div style={{ width: '100%', overflowWrap: 'break-word' }}>
<Title
order={1}
ta='center'
style={{
whiteSpace: 'normal',
fontSize: `clamp(20px, ${Math.max(50 - (config.website.title?.length ?? 0) / 2, 20)}px, 50px)`,
}}
>
<b>{config.website.title ?? 'Zipline'}</b>
</Title>
</div>
{invite && (
<Text ta='center' size='sm' c='dimmed'>
Youve been invited to join <b>{config?.website?.title ?? 'Zipline'}</b> by{' '}
<b>{invite.inviter?.username}</b>
</Text>
)}
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack my='sm'>
<TextInput
size='md'
placeholder='Enter your username...'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
},
}}
{...form.getInputProps('username', { withError: true })}
/>
<PasswordInput
size='md'
placeholder='Enter your password...'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
},
}}
{...form.getInputProps('password')}
/>
{config.website.tos && (
<Checkbox
label={
<Text size='xs'>
I agree to the{' '}
<Link to='/auth/tos' target='_blank'>
Terms of Service
</Link>
</Text>
}
required
{...form.getInputProps('tos', { type: 'checkbox' })}
/>
)}
<Button
size='md'
fullWidth
type='submit'
variant={config.website.loginBackground ? 'outline' : 'filled'}
leftSection={<IconUserPlus size='1rem' />}
>
Register
</Button>
</Stack>
</form>
<Stack my='xs'>
<Divider label='or' />
<Button
component={Link}
to='/auth/login'
size='md'
fullWidth
variant='outline'
leftSection={<IconLogin size='1rem' />}
>
Login
</Button>
</Stack>
</Paper>
</Center>
);
}
Component.displayName = 'Register';

58
src/pages/setup.tsx → src/client/pages/auth/setup.tsx Executable file → Normal file
View File

@@ -1,6 +1,6 @@
import { Response } from '@/lib/api/response';
import { getZipline } from '@/lib/db/models/zipline';
import { type Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { useTitle } from '@/lib/hooks/useTitle';
import {
Anchor,
Button,
@@ -18,11 +18,8 @@ import {
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { IconArrowBackUp, IconArrowForwardUp, IconCheck, IconX } from '@tabler/icons-react';
import { GetServerSideProps } from 'next';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { redirect, useNavigate } from 'react-router-dom';
import { mutate } from 'swr';
function LinkToDoc({ href, title, children }: { href: string; title: string; children: React.ReactNode }) {
@@ -36,8 +33,22 @@ function LinkToDoc({ href, title, children }: { href: string; title: string; chi
);
}
export default function Setup() {
const router = useRouter();
export async function loader() {
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 {};
}
export function Component() {
useTitle('Setup');
const navigate = useNavigate();
const [active, setActive] = useState(0);
const nextStep = () => setActive((current) => (current < 3 ? current + 1 : current));
@@ -99,18 +110,13 @@ export default function Setup() {
setActive(2);
} else {
mutate('/api/user', data as Response['/api/user']);
router.push('/dashboard');
navigate('/dashboard');
}
}
};
return (
<>
<Head>
<title>Zipline Setup</title>
<meta name='viewport' content='width=device-width, initial-scale=1' />
</Head>
<Paper withBorder p='xs' m='sm'>
<Stepper active={active} onStepClick={setActive} m='md'>
<Stepper.Step label='Welcome!' description='Setup Zipline'>
@@ -145,7 +151,11 @@ export default function Setup() {
<Text>
To see all of the available environment variables, please refer to the documentation{' '}
<Anchor component={Link} href='https://zipline.diced.sh/docs/config'>
<Anchor
href='https://zipline.diced.sh/docs/config'
target='_blank'
rel='noopener noreferrer'
>
here.
</Anchor>
</Text>
@@ -236,20 +246,4 @@ export default function Setup() {
);
}
export const getServerSideProps: GetServerSideProps = async () => {
const { firstSetup } = await getZipline();
if (!firstSetup)
return {
redirect: {
destination: '/dashboard',
permanent: false,
},
};
return {
props: {},
};
};
Setup.title = 'Setup';
Component.displayName = 'Setup';

View File

@@ -0,0 +1,41 @@
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';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Terms of Service');
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>
);
}
Component.displayName = 'Tos';

View File

@@ -0,0 +1,10 @@
import DashboardInvites from '@/components/pages/invites';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Invites');
return <DashboardInvites />;
}
Component.displayName = 'Dashboard/Admin/Invites';

View File

@@ -0,0 +1,10 @@
import DashboardServerSettings from '@/components/pages/serverSettings';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Server Settings');
return <DashboardServerSettings />;
}
Component.displayName = 'Dashboard/Admin/Settings';

View File

@@ -0,0 +1,23 @@
import ViewUserFiles from '@/components/pages/users/ViewUserFiles';
import { useTitle } from '@/lib/hooks/useTitle';
import { Params, redirect, useLoaderData } from 'react-router-dom';
export async function loader({ params }: { params: Params<string> }) {
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 };
}
export function Component() {
const { user } = useLoaderData<typeof loader>();
useTitle(`${user ? user.username : 'User'}'s files`);
return <ViewUserFiles />;
}
Component.displayName = 'DashboardAdminViewUserFiles';

View File

@@ -0,0 +1,10 @@
import DashboardUsers from '@/components/pages/users';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Users');
return <DashboardUsers />;
}
Component.displayName = 'Dashboard/Admin/Users';

View File

@@ -0,0 +1,10 @@
import DashboardFiles from '@/components/pages/files';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Files');
return <DashboardFiles />;
}
Component.displayName = 'Dashboard/Files';

View File

@@ -0,0 +1,10 @@
import DashboardFolders from '@/components/pages/folders';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Folders');
return <DashboardFolders />;
}
Component.displayName = 'Dashboard/Folders';

View File

@@ -0,0 +1,10 @@
import DashboardHome from '@/components/pages/dashboard';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle();
return <DashboardHome />;
}
Component.displayName = 'Dashboard/';

View File

@@ -0,0 +1,10 @@
import DashboardMetrics from '@/components/pages/metrics';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Metrics');
return <DashboardMetrics />;
}
Component.displayName = 'Dashboard/Metrics';

View File

@@ -0,0 +1,10 @@
import DashboardSettings from '@/components/pages/settings';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Settings');
return <DashboardSettings />;
}
Component.displayName = 'Dashboard/Settings';

View File

@@ -0,0 +1,10 @@
import UploadFile from '@/components/pages/upload/File';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Upload File');
return <UploadFile />;
}
Component.displayName = 'Dashboard/Upload/File';

View File

@@ -0,0 +1,10 @@
import UploadText from '@/components/pages/upload/Text';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Upload Text');
return <UploadText />;
}
Component.displayName = 'Dashboard/Upload/Text';

View File

@@ -0,0 +1,10 @@
import DashboardURLs from '@/components/pages/urls';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('URLs');
return <DashboardURLs />;
}
Component.displayName = 'Dashboard/URLs';

View File

@@ -0,0 +1,57 @@
import { type Response } from '@/lib/api/response';
import { ActionIcon, Container, Group, SimpleGrid, Skeleton, Title } from '@mantine/core';
import { IconUpload } from '@tabler/icons-react';
import { lazy, Suspense } from 'react';
import { Link, Params, useLoaderData } from 'react-router-dom';
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
export async function loader({ params }: { params: Params<string> }) {
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()) as Response['/api/server/folder/[id]'],
};
}
export function Component() {
const { folder } = useLoaderData<typeof loader>();
return (
<>
<Container my='lg'>
<Group>
<Title order={1}>{folder.name}</Title>
{folder.allowUploads && (
<Link to={`/folder/${folder.id}/upload`}>
<ActionIcon variant='outline'>
<IconUpload size='1rem' />
</ActionIcon>
</Link>
)}
</Group>
<SimpleGrid
my='sm'
cols={{
base: 1,
lg: 3,
md: 2,
}}
spacing='md'
>
{folder.files?.map((file: any) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} reduce />
</Suspense>
))}
</SimpleGrid>
</Container>
</>
);
}
Component.displayName = 'ViewFolderId';

View File

@@ -0,0 +1,55 @@
import ConfigProvider from '@/components/ConfigProvider';
import UploadFile from '@/components/pages/upload/File';
import { type Response } from '@/lib/api/response';
import { SafeConfig } from '@/lib/config/safe';
import { Anchor, Center, Container, Text } from '@mantine/core';
import { Link, Params, useLoaderData } from 'react-router-dom';
import useSWR from 'swr';
export async function loader({ params }: { params: Params<string> }) {
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()) as Response['/api/server/folder/[id]'],
};
}
export function Component() {
const { folder } = useLoaderData<typeof loader>();
const { data: config } = useSWR<Response['/api/server/public']>('/api/server/public', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
});
return (
<>
<Container my='lg'>
<ConfigProvider data={{ config: config as unknown as SafeConfig, codeMap: [] }}>
<UploadFile title={`Upload files to ${folder.name}`} folder={folder.id} />
<Center>
<Text c='dimmed' ta='center'>
{folder.public ? (
<>
This folder is{' '}
<Anchor component={Link} to={`/folder/${folder.id}`}>
public
</Anchor>
. Anyone with the link can view its contents and upload files.
</>
) : (
"Only the owner can view this folder's contents. However, anyone can upload files, and they can still access their uploaded files if they have the link to the specific file."
)}
</Text>
</Center>
</ConfigProvider>
</Container>
</>
);
}
Component.displayName = 'ViewFolderIdUpload';

View File

@@ -0,0 +1,245 @@
import DashboardFileType from '@/components/file/DashboardFileType';
import TagPill from '@/components/pages/files/tags/TagPill';
import { File } from '@/lib/db/models/file';
import { User } from '@/lib/db/models/user';
import { parseString } from '@/lib/parser';
import { type parserMetrics } from '@/lib/parser/metrics';
import { formatRootUrl } from '@/lib/url';
import {
ActionIcon,
Anchor,
Box,
Button,
Center,
Collapse,
Group,
Modal,
Paper,
PasswordInput,
Text,
Tooltip,
Typography,
} from '@mantine/core';
import { IconDownload, IconExternalLink, IconInfoCircleFilled } from '@tabler/icons-react';
import * as sanitize from 'isomorphic-dompurify';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useSsrData } from '../../../components/ZiplineSSRProvider';
import { getFile } from '../../ssr-view/server';
type SsrData = {
file: Partial<NonNullable<Awaited<ReturnType<typeof getFile>>>>;
password?: boolean;
code: boolean;
user?: Partial<User>;
host: string;
pw?: string | null;
metrics?: Awaited<ReturnType<typeof parserMetrics>>;
filesRoute?: string;
};
export default function ViewFileId() {
const data = useSsrData<SsrData>();
if (!data) return null;
const { file, password, code, user, host, metrics, filesRoute, pw } = data;
const [passwordValue, setPassword] = useState<string>('');
const [passwordError, setPasswordError] = useState<string>('');
const [detailsOpen, setDetailsOpen] = useState<boolean>(false);
return password && !pw ? (
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
<form
onSubmit={async (e) => {
e.preventDefault();
const res = await fetch(`/api/user/files/${file.id}/password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: passwordValue.trim() }),
});
if (res.ok) {
window.location.reload();
} else {
setPasswordError('Invalid password');
}
}}
>
<PasswordInput
description='This file is password protected, enter password to view it'
required
mb='sm'
value={passwordValue}
onChange={(event) => setPassword(event.currentTarget.value)}
error={passwordError}
/>
<Button
fullWidth
variant='outline'
my='sm'
type='submit'
disabled={passwordValue.trim().length === 0}
>
Verify
</Button>
</form>
</Modal>
) : code ? (
<>
<Paper withBorder style={{ borderTop: 0, borderLeft: 0, borderRight: 0 }}>
<Group justify='space-between' py={5} px='xs'>
<Text c='dimmed'>{file.name}</Text>
<Group>
<ActionIcon size='md' variant='outline' onClick={() => setDetailsOpen((o) => !o)}>
<IconInfoCircleFilled size='1rem' />
</ActionIcon>
<ActionIcon
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}?download=true${pw ? `&pw=${pw}` : ''}`}
target='_blank'
>
<IconDownload size='1rem' />
</ActionIcon>
</Group>
</Group>
</Paper>
<Collapse in={detailsOpen}>
<Paper m='md' p='md' withBorder>
{user?.view!.content && (
<Typography>
<Text
ta={user?.view!.align ?? 'left'}
dangerouslySetInnerHTML={{
__html: sanitize.sanitize(
parseString(user.view.content, {
file: file as unknown as File,
link: {
returned: `${host}${formatRootUrl(filesRoute ?? '/u', file.name!)}`,
raw: `${host}/raw/${file.name}`,
},
...metrics,
}) ?? '',
{
USE_PROFILES: { html: true },
FORBID_TAGS: ['style', 'script'],
},
),
}}
/>
</Typography>
)}
</Paper>
</Collapse>
{file.name!.endsWith('.md') || file.name!.endsWith('.tex') ? (
<Paper m='md' p='md' withBorder>
<DashboardFileType file={file as unknown as File} password={pw} show code={code} />
</Paper>
) : (
<Box m='sm'>
<DashboardFileType file={file as unknown as File} password={pw} show code={code} />
</Box>
)}
</>
) : (
<>
<Center h='100%'>
<Paper m='md' p='md' shadow='md' radius='md' withBorder>
<Group justify='space-between' mb='sm'>
<Group>
<Text size='lg' fw={700} display='flex'>
{file.name}{' '}
</Text>
{user?.view!.showTags && (
<Group gap={4}>
{file.tags?.map((tag) => (
<TagPill key={tag.id} tag={tag} />
))}
</Group>
)}
{user?.view!.showFolder &&
file.Folder &&
(file.Folder.public ? (
<Tooltip label='View folder'>
<Anchor component={Link} ml='sm' to={`/folder/${file.Folder.id}`}>
{file.Folder.name}
</Anchor>
</Tooltip>
) : (
<Text ml='sm' size='sm' c='dimmed'>
{file.Folder.name}
</Text>
))}
{user?.view!.showMimetype && (
<Text size='sm' c='dimmed' ml='sm' style={{ alignSelf: 'center' }}>
{file.type}
</Text>
)}
</Group>
<ActionIcon.Group>
<Tooltip label='View raw file'>
<ActionIcon
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}${pw ? `?pw=${pw}` : ''}`}
target='_blank'
>
<IconExternalLink size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Download file'>
<ActionIcon
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}?download=true${pw ? `&pw=${pw}` : ''}`}
target='_blank'
>
<IconDownload size='1rem' />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</Group>
<DashboardFileType allowZoom file={file as unknown as File} password={pw} show />
{user?.view!.content && (
<Typography>
<Text
mt='sm'
ta={user?.view.align ?? 'left'}
dangerouslySetInnerHTML={{
__html: sanitize.sanitize(
parseString(user?.view.content, {
file: file as unknown as File,
link: {
returned: `${host}${formatRootUrl(filesRoute ?? '/u', file.name!)}`,
raw: `${host}/raw/${file.name}`,
},
user: user as User,
...metrics,
}) ?? '',
{
USE_PROFILES: { html: true },
FORBID_TAGS: ['script'],
},
),
}}
/>
</Typography>
)}
</Paper>
</Center>
</>
);
}

View File

@@ -0,0 +1,65 @@
import { useSsrData } from '@/components/ZiplineSSRProvider';
import { Anchor, Button, Modal, PasswordInput } from '@mantine/core';
import { useEffect, useState } from 'react';
export default function ViewUrlId() {
const data = useSsrData<{
url: { id: string; destination?: string };
password?: boolean;
}>();
if (!data) return null;
const { url, password } = data;
const [passwordValue, setPassword] = useState<string>('');
const [passwordError, setPasswordError] = useState<string>('');
useEffect(() => {
if (!password && url.destination) window.location.href = url.destination;
}, []);
return password ? (
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
<form
onSubmit={async (e) => {
e.preventDefault();
const res = await fetch(`/api/user/urls/${url.id}/password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: passwordValue.trim() }),
});
if (res.ok) {
window.location.reload();
} else {
setPasswordError('Invalid password');
}
}}
>
<PasswordInput
description='This link is password protected, enter password to view it'
required
mb='sm'
value={passwordValue}
onChange={(event) => setPassword(event.currentTarget.value)}
error={passwordError}
/>
<Button
fullWidth
variant='outline'
my='sm'
type='submit'
disabled={passwordValue.trim().length === 0}
>
Verify
</Button>
</form>
</Modal>
) : (
<p>
Redirecting to <Anchor href={url.destination!}>{url.destination!}</Anchor>
</p>
);
}

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

@@ -0,0 +1,114 @@
import Layout from '@/components/Layout';
import { Response as ApiResponse } from '@/lib/api/response';
import { isAdministrator } from '@/lib/role';
import { createBrowserRouter, redirect } from 'react-router-dom';
import DashboardErrorBoundary from './error/DashboardErrorBoundary';
import RootErrorBoundary from './error/RootErrorBoundary';
import FourOhFour from './pages/404';
import Login from './pages/auth/login';
import Logout from './pages/auth/logout';
import Root from './Root';
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', lazy: () => import('./pages/auth/register') },
{
path: 'setup',
lazy: () => import('./pages/auth/setup'),
},
{ path: 'tos', lazy: () => import('./pages/auth/tos') },
],
},
{
path: '/dashboard',
Component: Layout,
loader: dashboardLoader,
children: [
{
ErrorBoundary: DashboardErrorBoundary,
children: [
{ index: true, lazy: () => import('./pages/dashboard/index') },
{ path: 'metrics', lazy: () => import('./pages/dashboard/metrics') },
{ path: 'settings', lazy: () => import('./pages/dashboard/settings') },
{ path: 'files', lazy: () => import('./pages/dashboard/files') },
{ path: 'folders', lazy: () => import('./pages/dashboard/folders') },
{ path: 'urls', lazy: () => import('./pages/dashboard/urls') },
{
path: 'upload',
children: [
{ path: 'file', lazy: () => import('./pages/dashboard/upload/file') },
{ path: 'text', lazy: () => import('./pages/dashboard/upload/text') },
],
},
{
path: 'admin',
loader: async () => {
const res = await fetch('/api/user');
if (!res.ok) {
return redirect('/auth/login');
}
const { user } = await res.json();
if (!isAdministrator(user.role)) return redirect('/dashboard');
},
children: [
{ path: 'invites', lazy: () => import('./pages/dashboard/admin/invites') },
{ path: 'settings', lazy: () => import('./pages/dashboard/admin/settings') },
{
path: 'users',
children: [
{ index: true, lazy: () => import('./pages/dashboard/admin/users') },
{
path: ':id/files',
lazy: () => import('./pages/dashboard/admin/users/[id]/files'),
},
],
},
],
},
],
},
],
},
{
path: 'folder/:id',
children: [
{
index: true,
lazy: () => import('./pages/folder/[id]'),
},
{
path: 'upload',
lazy: () => import('./pages/folder/[id]/upload'),
},
],
},
],
},
],
},
]);

View File

@@ -0,0 +1,25 @@
import '@mantine/charts/styles.css';
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/dropzone/styles.css';
import '@mantine/notifications/styles.css';
import 'mantine-datatable/styles.css';
import ZiplineSSRProvider from '@/components/ZiplineSSRProvider';
import { ZIPLINE_SSR_PROP } from '@/lib/ssr/constants';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { createRoutes } from './routes';
const router = createBrowserRouter(createRoutes());
const initialData = (window as any)[ZIPLINE_SSR_PROP];
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ZiplineSSRProvider ssrData={initialData}>
<RouterProvider router={router} />
</ZiplineSSRProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--zipline-ssr-meta-->
</head>
<body>
<div id="root"><!--zipline-ssr-insert--></div>
<script type="module" src="/ssr-view-url/client.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,18 @@
import { ZiplineTheme } from '@/lib/theme';
import Root from '../Root';
import { Config } from '@/lib/config/validate';
import ViewUrlId from '../pages/view/url/[id]';
export const createRoutes = (themes?: ZiplineTheme[], defaultTheme?: Config['website']['theme']) => [
{
path: '/view/url',
Component:
typeof window === 'undefined' ? undefined : () => <Root themes={themes} defaultTheme={defaultTheme} />,
children: [
{
path: ':id',
Component: () => <ViewUrlId />,
},
],
},
];

View File

@@ -0,0 +1,95 @@
import * as cookie from 'cookie';
import { FastifyRequest } from 'fastify';
import { config as zConfig } from '@/lib/config';
import { Config } from '@/lib/config/validate';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { renderHtml } from '@/lib/ssr/renderHtml';
import { ZiplineTheme } from '@/lib/theme';
import { createRoutes } from './routes'; // This should include the `/url/:id` route
export async function render(
{
themes,
defaultTheme,
req,
}: {
themes: ZiplineTheme[];
defaultTheme: Config['website']['theme'];
req: FastifyRequest;
},
url: string,
) {
const routes = createRoutes(themes, defaultTheme);
const id = url.split('/').pop();
if (!id) return { html: 'Not Found', meta: '', status: 404 };
const { config: libConfig, reloadSettings } = await import('@/lib/config');
if (!libConfig) await reloadSettings();
const urlEntry = await prisma.url.findFirst({
where: {
OR: [{ vanity: id }, { code: id }, { id }],
},
select: {
id: true,
password: true,
destination: true,
maxViews: true,
views: true,
enabled: true,
},
});
if (!urlEntry || !urlEntry.enabled) return { html: 'Not Found', meta: '', status: 404 };
if (urlEntry.maxViews && urlEntry.views >= urlEntry.maxViews) {
if (zConfig.features.deleteOnMaxViews) {
await prisma.url.delete({ where: { id: urlEntry.id } });
}
return { html: 'Gone', meta: '', status: 410 };
}
const cookies = cookie.parse(req.headers.cookie || '');
const pw = cookies[`url_pw_${urlEntry.id}`];
const hasPassword = !!urlEntry.password;
const data = {
url: { ...urlEntry },
password: hasPassword,
};
if (hasPassword) {
delete (data.url as any).password;
if (pw) {
const verified = await verifyPassword(pw, urlEntry.password!);
if (!verified) {
delete (data.url as any).destination;
return renderHtml(routes, { url, data, status: 403 });
}
} else {
delete (data.url as any).destination;
return renderHtml(routes, { url, data, status: 403 });
}
}
delete (data.url as any).password;
await prisma.url.update({
where: { id: urlEntry.id },
data: { views: { increment: 1 } },
});
if (data.url.destination) {
return {
html: '',
meta: '',
redirect: data.url.destination,
status: 301,
};
}
return renderHtml(routes, { url, data, status: 200 });
}

View File

@@ -0,0 +1,25 @@
import '@mantine/charts/styles.css';
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/dropzone/styles.css';
import '@mantine/notifications/styles.css';
import 'mantine-datatable/styles.css';
import ZiplineSSRProvider from '@/components/ZiplineSSRProvider';
import { ZIPLINE_SSR_PROP } from '@/lib/ssr/constants';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { createRoutes } from './routes';
const router = createBrowserRouter(createRoutes());
const initialData = (window as any)[ZIPLINE_SSR_PROP];
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ZiplineSSRProvider ssrData={initialData}>
<RouterProvider router={router} />
</ZiplineSSRProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--zipline-ssr-meta-->
</head>
<body>
<div id="root"><!--zipline-ssr-insert--></div>
<script type="module" src="/ssr-view/client.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,18 @@
import { ZiplineTheme } from '@/lib/theme';
import Root from '../Root';
import ViewFileId from '../pages/view/[id]';
import { Config } from '@/lib/config/validate';
export const createRoutes = (themes?: ZiplineTheme[], defaultTheme?: Config['website']['theme']) => [
{
path: '/view',
Component:
typeof window === 'undefined' ? undefined : () => <Root themes={themes} defaultTheme={defaultTheme} />,
children: [
{
path: ':id',
Component: () => <ViewFileId />,
},
],
},
];

View File

@@ -0,0 +1,287 @@
import '@mantine/charts/styles.css';
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/dropzone/styles.css';
import '@mantine/notifications/styles.css';
import 'mantine-datatable/styles.css';
import { isCode } from '@/lib/code';
import { config as zConfig } from '@/lib/config';
import type { Config } from '@/lib/config/validate';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { File, fileSelect } from '@/lib/db/models/file';
import { User, userSelect } from '@/lib/db/models/user';
import { parseString } from '@/lib/parser';
import { parserMetrics } from '@/lib/parser/metrics';
import { createZiplineSsr } from '@/lib/ssr/createZiplineSsr';
import type { ZiplineTheme } from '@/lib/theme';
import { readThemes } from '@/lib/theme/file';
import * as cookie from 'cookie';
import { FastifyRequest } from 'fastify';
import { renderToString } from 'react-dom/server';
import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router-dom';
import { createRoutes } from './routes';
export const getFile = async (id: string) =>
prisma.file.findFirst({
where: { name: id as string },
select: {
...fileSelect,
password: true,
userId: true,
thumbnail: { select: { path: true } },
tags: { select: { id: true, name: true, color: true } },
Folder: { select: { id: true, public: true, name: true } },
},
});
export async function render(
{
defaultTheme,
req,
}: {
themes: ZiplineTheme[];
defaultTheme: Config['website']['theme'];
req: FastifyRequest;
},
url: string,
) {
const id = url.split('/').pop();
if (!id) return { html: 'Not Found', meta: '', status: 404 };
const { config: libConfig, reloadSettings } = await import('@/lib/config');
if (!libConfig) await reloadSettings();
const file = await getFile(id);
if (!file || !file.userId) return { html: 'Not Found', meta: '', status: 404 };
if (file.maxViews && file.views >= file.maxViews) return { html: 'Gone', meta: '', status: 410 };
if (file.deletesAt && file.deletesAt <= new Date()) return { html: 'Expired', meta: '', status: 410 };
const user = await prisma.user.findFirst({
where: { id: file.userId },
select: {
...userSelect,
oauthProviders: false,
passkeys: false,
sessions: false,
totpSecret: false,
quota: false,
},
});
if (!user) return { html: 'Not Found', meta: '', status: 404 };
let host = req.headers.host || 'localhost';
const proto = req.headers['x-forwarded-proto'];
try {
if (
JSON.parse(req.headers['cf-visitor'] as string)?.scheme === 'https' ||
proto === 'https' ||
zConfig.core.returnHttpsUrls
) {
host = `https://${host}`;
} else {
host = `http://${host}`;
}
} catch {
host = proto === 'https' || zConfig.core.returnHttpsUrls ? `https://${host}` : `http://${host}`;
}
// Date normalization
(file as any).createdAt = file.createdAt.toISOString();
(file as any).updatedAt = file.updatedAt.toISOString();
(file as any).deletesAt = file.deletesAt?.toISOString() || null;
(user as any).createdAt = user.createdAt.toISOString();
(user as any).updatedAt = user.updatedAt.toISOString();
const code = await isCode(file.name);
const themes = await readThemes();
const metrics = await parserMetrics(user.id);
const config = { website: { theme: zConfig.website.theme } };
const cookies = cookie.parse(req.headers.cookie || '');
const pw = cookies[`file_pw_${file.id}`];
const hasPassword = !!file.password;
if (hasPassword) {
if (pw) {
const verified = await verifyPassword(pw, file.password!);
if (!verified) return { html: 'Forbidden', meta: '', status: 403 };
delete (file as any).password;
} else {
delete (file as any).password;
const data = {
file: { id: file.id, name: file.name, type: file.type },
password: true,
code,
user,
host,
themes,
metrics,
config,
};
const routes = createRoutes(themes, defaultTheme);
const { query } = createStaticHandler(routes);
const context = await query(
new Request('http://client' + url, {
method: 'GET',
headers: new Headers({ accept: 'text/html' }),
}),
);
if (context instanceof Response) {
return context;
}
const router = createStaticRouter(routes, context);
const html = renderToString(<StaticRouterProvider context={context} router={router} />);
return {
html,
meta: `<title>Password Protected</title>\n${createZiplineSsr(data)}`,
};
}
}
await prisma.file.update({
where: { id: file.id },
data: { views: { increment: 1 } },
});
const data = {
file,
password: hasPassword,
pw: pw || null,
code,
user,
host,
themes,
metrics,
filesRoute: zConfig.files.route,
config,
};
const routes = createRoutes(themes, defaultTheme);
const { query } = createStaticHandler(routes);
const context = await query(
new Request('http://client' + url, {
method: 'GET',
headers: new Headers({ accept: 'text/html' }),
}),
);
if (context instanceof Response) {
return context;
}
const router = createStaticRouter(routes, context);
const html = renderToString(<StaticRouterProvider context={context} router={router} />);
const meta = `
${
user?.view?.embedTitle && user.view.embed
? `<meta property="og:title" content="${
parseString(user.view.embedTitle, {
file: file as unknown as File,
user: user as User,
...metrics,
}) ?? ''
}" />`
: ''
}
${
user?.view?.embedDescription && user.view.embed
? `<meta property="og:description" content="${
parseString(user.view.embedDescription, {
file: file as unknown as File,
user: user as User,
...metrics,
}) ?? ''
}" />`
: ''
}
${
user?.view?.embedSiteName && user.view.embed
? `<meta property="og:site_name" content="${
parseString(user.view.embedSiteName, {
file: file as unknown as File,
user: user as User,
...metrics,
}) ?? ''
}" />`
: ''
}
${
user?.view?.embedColor && user.view.embed
? `<meta property="theme-color" content="${
parseString(user.view.embedColor, {
file: file as unknown as File,
user: user as User,
...metrics,
}) ?? ''
}" />`
: ''
}
${
file.type?.startsWith('image')
? `
<meta property="og:type" content="image" />
<meta property="og:image" itemProp="image" content="${host}/raw/${file.name}" />
<meta property="og:url" content="${host}/raw/${file.name}" />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:image" content="${host}/raw/${file.name}" />
<meta property="twitter:title" content="${file.name}" />
`
: ''
}
${
file.type?.startsWith('video')
? `
${file.thumbnail ? `<meta property="og:image" content="${host}/raw/${file.thumbnail.path}" />` : ''}
<meta property="og:type" content="video.other" />
<meta property="og:video:url" content="${host}/raw/${file.name}" />
<meta property="og:video:width" content="1920" />
<meta property="og:video:height" content="1080" />
`
: ''
}
${
file.type?.startsWith('audio')
? `
<meta name="twitter:card" content="player" />
<meta name="twitter:player" content="${host}/raw/${file.name}" />
<meta name="twitter:player:stream" content="${host}/raw/${file.name}" />
<meta name="twitter:player:stream:content_type" content="${file.type}" />
<meta name="twitter:title" content="${file.name}" />
<meta name="twitter:player:width" content="720" />
<meta name="twitter:player:height" content="480" />
<meta property="og:type" content="music.song" />
<meta property="og:url" content="${host}/raw/${file.name}" />
<meta property="og:audio" content="${host}/raw/${file.name}" />
<meta property="og:audio:secure_url" content="${host}/raw/${file.name}" />
<meta property="og:audio:type" content="${file.type}" />
`
: ''
}
${
!file.type?.startsWith('video') && !file.type?.startsWith('image')
? `
<meta property="og:url" content="${host}/raw/${file.name}" />
`
: ''
}
<title>${file.name}</title>
`;
return {
html,
meta: `${meta}\n${createZiplineSsr(data)}`,
};
}

View File

@@ -1,21 +1,30 @@
import { SafeConfig } from '@/lib/config/safe';
import { ApiServerSettingsWebResponse } from '@/server/routes/api/server/settings';
import { createContext, useContext } from 'react';
const ConfigContext = createContext<SafeConfig | null>(null);
type ConfigContextType = ApiServerSettingsWebResponse;
const ConfigContext = createContext<ConfigContextType | null>(null);
export function useConfig() {
const ctx = useContext(ConfigContext);
if (!ctx) throw new Error('useConfig must be used within a ConfigProvider');
return ctx;
return ctx.config;
}
export function useCodeMap() {
const ctx = useContext(ConfigContext);
if (!ctx) throw new Error('useCodeMap must be used within a ConfigProvider');
return ctx.codeMap;
}
export default function ConfigProvider({
config,
data,
children,
}: {
config: SafeConfig;
data: ConfigContextType;
children: React.ReactNode;
}) {
return <ConfigContext.Provider value={config}>{children}</ConfigContext.Provider>;
return <ConfigContext.Provider value={data}>{children}</ConfigContext.Provider>;
}

View File

@@ -3,6 +3,7 @@ import type { SafeConfig } from '@/lib/config/safe';
import { fetchApi } from '@/lib/fetchApi';
import useAvatar from '@/lib/hooks/useAvatar';
import useLogin from '@/lib/hooks/useLogin';
import { Outlet, useLocation } from 'react-router-dom';
import { isAdministrator } from '@/lib/role';
import { useUserStore } from '@/lib/store/user';
import {
@@ -44,11 +45,11 @@ import {
IconUpload,
IconUsersGroup,
} from '@tabler/icons-react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
import ConfigProvider from './ConfigProvider';
import VersionBadge from './VersionBadge';
import { Link, useLoaderData } from 'react-router-dom';
import { dashboardLoader } from '../client/routes';
type NavLinks = {
label: string;
@@ -142,14 +143,17 @@ const navLinks: NavLinks[] = [
},
];
export default function Layout({ children, config }: { children: React.ReactNode; config: SafeConfig }) {
export default function Layout() {
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const [opened, setOpened] = useState(false);
const router = useRouter();
const modals = useModals();
const clipboard = useClipboard();
const setUser = useUserStore((s) => s.setUser);
const location = useLocation();
const loaderData = useLoaderData<typeof dashboardLoader>();
const config = loaderData.config;
const { user, mutate } = useLogin();
const { avatar } = useAvatar();
@@ -275,7 +279,8 @@ export default function Layout({ children, config }: { children: React.ReactNode
<Menu.Item
leftSection={<IconSettingsFilled size='1rem' />}
component={Link}
href='/dashboard/settings'
to='/dashboard/settings'
prefetch='intent'
>
Settings
</Menu.Item>
@@ -284,7 +289,8 @@ export default function Layout({ children, config }: { children: React.ReactNode
<Menu.Item
leftSection={<IconAdjustments size='1rem' />}
component={Link}
href='/dashboard/admin/settings'
to='/dashboard/admin/settings'
prefetch='intent'
>
Server Settings
</Menu.Item>
@@ -295,7 +301,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
color='red'
leftSection={<IconLogout size='1rem' />}
component={Link}
href='/auth/logout'
to='/auth/logout'
>
Logout
</Menu.Item>
@@ -322,9 +328,10 @@ export default function Layout({ children, config }: { children: React.ReactNode
leftSection={link.icon}
variant='light'
rightSection={<IconChevronRight size='0.7rem' />}
active={router.pathname === link.href}
active={location.pathname === link.href}
component={Link}
href={link.href || ''}
to={link.href || ''}
prefetch='intent'
/>
);
} else {
@@ -335,7 +342,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
leftSection={link.icon}
variant='light'
rightSection={<IconChevronRight size='0.7rem' />}
defaultOpened={link.active(router.pathname)}
defaultOpened={link.active(location.pathname)}
>
{link.links
.filter(
@@ -348,9 +355,10 @@ export default function Layout({ children, config }: { children: React.ReactNode
leftSection={sublink.icon}
rightSection={<IconChevronRight size='0.7rem' />}
variant='light'
active={router.pathname === sublink.href}
active={location.pathname === sublink.href}
component={Link}
href={sublink.href || ''}
to={sublink.href || ''}
prefetch='intent'
/>
))}
</NavLink>
@@ -372,7 +380,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
leftSection={<IconExternalLink size='1rem' />}
variant='light'
component={Link}
href={url}
to={url}
target='_blank'
/>
))}
@@ -382,9 +390,9 @@ export default function Layout({ children, config }: { children: React.ReactNode
</AppShell.Navbar>
<AppShell.Main>
<ConfigProvider config={config}>
<ConfigProvider data={loaderData}>
<Paper m='lg' withBorder p='xs'>
{children}
<Outlet />
</Paper>
</ConfigProvider>
</AppShell.Main>

View File

@@ -1,3 +1,4 @@
import { Response } from '@/lib/api/response';
import { Config } from '@/lib/config/validate';
import { useSettingsStore } from '@/lib/store/settings';
import { useUserStore } from '@/lib/store/user';
@@ -6,6 +7,7 @@ import dark_blue from '@/lib/theme/builtins/dark_blue';
import { MantineProvider, createTheme } from '@mantine/core';
import { useColorScheme } from '@mantine/hooks';
import { createContext, useContext } from 'react';
import useSWR from 'swr';
import { useShallow } from 'zustand/shallow';
const ThemeContext = createContext<{
@@ -21,15 +23,25 @@ export function useThemes() {
return ctx.themes;
}
export default function Theming({
themes,
defaultTheme,
export default function ThemeProvider({
ssrThemes,
ssrDefaultTheme,
children,
}: {
themes: ZiplineTheme[];
ssrThemes?: ZiplineTheme[];
ssrDefaultTheme?: Config['website']['theme'];
children: React.ReactNode;
defaultTheme?: Config['website']['theme'];
}) {
const { data: clientThemes } = useSWR<Response['/api/server/themes']>('/api/server/themes', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
});
const themes = ssrThemes ?? clientThemes?.themes;
const defaultTheme = ssrDefaultTheme ?? clientThemes?.defaultTheme;
const user = useUserStore((state) => state.user);
const [userTheme, preferredDark, preferredLight] = useSettingsStore(
useShallow((state) => [state.settings.theme, state.settings.themeDark, state.settings.themeLight]),
@@ -53,7 +65,7 @@ export default function Theming({
}
return (
<ThemeContext.Provider value={{ themes }}>
<ThemeContext.Provider value={{ themes: themes ?? [] }}>
<MantineProvider
defaultColorScheme={theme.colorScheme as unknown as any}
forceColorScheme={theme.colorScheme as unknown as any}

View File

@@ -0,0 +1,19 @@
import { createContext, useContext } from 'react';
export const ZiplineSSRContext = createContext<any>(null);
export function useSsrData<T>(): T {
const ctx = useContext(ZiplineSSRContext);
return ctx as T;
}
export default function ZiplineSSRProvider({
children,
ssrData,
}: {
children: React.ReactNode;
ssrData: any;
}) {
return <ZiplineSSRContext.Provider value={ssrData}>{children}</ZiplineSSRContext.Provider>;
}

View File

@@ -15,7 +15,6 @@ import { useEffect, useState, useCallback, useMemo } from 'react';
import { renderMode } from '../pages/upload/renderMode';
import Render from '../render/Render';
import fileIcon from './fileIcon';
import { parseAsStringLiteral, useQueryState } from 'nuqs';
function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
return (
@@ -78,8 +77,6 @@ export default function DashboardFileType({
code?: boolean;
allowZoom?: boolean;
}) {
const [overrideType] = useQueryState('otype', parseAsStringLiteral(['video', 'audio', 'image', 'text']));
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
const dbFile = 'id' in file;
@@ -135,7 +132,7 @@ export default function DashboardFileType({
if (code) {
setType('text');
getText();
} else if (overrideType === 'text' || type === 'text') {
} else if (type === 'text') {
getText();
} else {
return;
@@ -167,7 +164,7 @@ export default function DashboardFileType({
</Paper>
);
switch (overrideType || type) {
switch (type) {
case 'video':
return show ? (
<video

View File

@@ -16,7 +16,7 @@ import {
IconTrashFilled,
IconTrashXFilled,
} from '@tabler/icons-react';
import Link from 'next/link';
import { Link } from 'react-router-dom';
import { mutate } from 'swr';
export function viewFile(file: File) {
@@ -37,7 +37,7 @@ export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>)
notifications.show({
title: 'Copied link',
message: (
<Anchor component={Link} href={url}>
<Anchor component={Link} to={url}>
{url}
</Anchor>
),

View File

@@ -4,18 +4,15 @@ import { bytes } from '@/lib/bytes';
import useLogin from '@/lib/hooks/useLogin';
import { Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text, Title } from '@mantine/core';
import { IconDeviceSdCard, IconEyeFilled, IconFiles, IconLink, IconStarFilled } from '@tabler/icons-react';
import dynamic from 'next/dynamic';
import { lazy, Suspense } from 'react';
import useSWR from 'swr';
const DashboardFile = dynamic(() => import('@/components/file/DashboardFile'), {
loading: () => <Skeleton height={350} animate />,
});
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
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>
@@ -63,7 +60,9 @@ export default function DashboardHome() {
) : recent?.length !== 0 ? (
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 'sm', md: 'md' }}>
{recent!.map((file, i) => (
<DashboardFile key={i} file={file} />
<Suspense fallback={<Skeleton height={350} animate />} key={i}>
<DashboardFile file={file} />
</Suspense>
))}
</SimpleGrid>
) : (

View File

@@ -3,10 +3,9 @@ import { IncompleteFile } from '@/lib/db/models/incompleteFile';
import { fetchApi } from '@/lib/fetchApi';
import { ActionIcon, Badge, Button, Card, Group, Modal, Paper, Stack, Text, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IncompleteFileStatus } from '../../../../generated/client';
import { IncompleteFileStatus } from '@/prisma/client';
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { ReactNode } from 'react';
import { ReactNode, useState } from 'react';
import useSWR from 'swr';
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
@@ -33,7 +32,7 @@ const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
};
export default function PendingFilesButton() {
const [open, setOpen] = useQueryState('popen', parseAsBoolean.withDefault(false));
const [open, setOpen] = useState(false);
const { data: incompleteFiles, mutate } = useSWR<
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>

View File

@@ -6,10 +6,10 @@ import FileTable from './views/FileTable';
import Files from './views/Files';
import TagsButton from './tags/TagsButton';
import PendingFilesButton from './PendingFilesButton';
import Link from 'next/link';
import { IconFileUpload } from '@tabler/icons-react';
import { Link } from 'react-router-dom';
export default function DashbaordFiles() {
export default function DashboardFiles() {
const view = useViewStore((state) => state.files);
return (
@@ -18,7 +18,7 @@ export default function DashbaordFiles() {
<Title>Files</Title>
<Tooltip label='Upload a file'>
<Link href='/dashboard/upload/file'>
<Link to='/dashboard/upload/file'>
<ActionIcon variant='outline'>
<IconFileUpload size='1rem' />
</ActionIcon>

View File

@@ -5,7 +5,6 @@ import { fetchApi } from '@/lib/fetchApi';
import { ActionIcon, Group, Modal, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconPencil, IconPlus, IconTagOff, IconTags, IconTrashFilled } from '@tabler/icons-react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useState } from 'react';
import useSWR from 'swr';
import CreateTagModal from './CreateTagModal';
@@ -13,8 +12,8 @@ import EditTagModal from './EditTagModal';
import TagPill from './TagPill';
export default function TagsButton() {
const [open, setOpen] = useQueryState('topen', parseAsBoolean.withDefault(false));
const [createModalOpen, setCreateModalOpen] = useQueryState('ctopen', parseAsBoolean.withDefault(false));
const [open, setOpen] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
const { data: tags, mutate } = useSWR<Extract<Tag[], Response['/api/user/tags']>>('/api/user/tags');

View File

@@ -1,3 +1,4 @@
import { useQueryState } from '@/lib/hooks/useQueryState';
import {
Accordion,
Button,
@@ -12,17 +13,14 @@ import {
Title,
} from '@mantine/core';
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { parseAsInteger, useQueryState } from 'nuqs';
import { Link } from 'react-router-dom';
import { useApiPagination } from '../useApiPagination';
import { lazy, Suspense } from 'react';
const DashboardFile = dynamic(() => import('@/components/file/DashboardFile'), {
loading: () => <Skeleton height={350} animate />,
});
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
export default function FavoriteFiles() {
const [page, setPage] = useQueryState('fpage', parseAsInteger.withDefault(1));
const [page, setPage] = useQueryState('fpage', 1);
const { data, isLoading } = useApiPagination({
page,
@@ -55,7 +53,11 @@ export default function FavoriteFiles() {
<LoadingOverlay visible />
</Paper>
) : (data?.page.length ?? 0 > 0) ? (
data?.page.map((file) => <DashboardFile key={file.id} file={file} />)
data?.page.map((file) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} />
</Suspense>
))
) : (
<Paper withBorder p='sm'>
<Center>
@@ -69,7 +71,7 @@ export default function FavoriteFiles() {
size='compact-sm'
leftSection={<IconFileUpload size='1rem' />}
component={Link}
href='/dashboard/upload/file'
to='/dashboard/upload/file'
>
Upload a file
</Button>

View File

@@ -1,5 +1,4 @@
import RelativeDate from '@/components/RelativeDate';
import FileModal from '@/components/file/DashboardFile/FileModal';
import { addMultipleToFolder, copyFile, deleteFile, downloadFile } from '@/components/file/actions';
import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
@@ -38,13 +37,15 @@ import {
IconTrashFilled,
} from '@tabler/icons-react';
import { DataTable } from 'mantine-datatable';
import Link from 'next/link';
import { parseAsBoolean, parseAsInteger, parseAsStringLiteral, useQueryState } from 'nuqs';
import { useEffect, useReducer, useState } from 'react';
import { lazy, 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 { useQueryState } from '@/lib/hooks/useQueryState';
const FileModal = lazy(() => import('@/components/file/DashboardFile/FileModal'));
type ReducerQuery = {
state: { name: string; originalName: string; type: string; tags: string; id: string };
@@ -186,30 +187,24 @@ export default function FileTable({ id }: { id?: string }) {
'/api/user/folders?noincl=true',
);
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [perpage, setPerpage] = useState<number>(20);
const [sort, setSort] = useQueryState(
'sort',
parseAsStringLiteral([
'id',
'createdAt',
'updatedAt',
'deletesAt',
'name',
'originalName',
'size',
'type',
'views',
'favorite',
]).withDefault('createdAt'),
);
const [order, setOrder] = useQueryState<'asc' | 'desc'>(
'order',
parseAsStringLiteral(['asc', 'desc']).withDefault('desc'),
);
const [page, setPage] = useQueryState('page', 1);
const [perpage, setPerpage] = useState(20);
const [sort, setSort] = useState<
| 'id'
| 'createdAt'
| 'updatedAt'
| 'deletesAt'
| 'name'
| 'originalName'
| 'size'
| 'type'
| 'views'
| 'favorite'
>('createdAt');
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [idSearchOpen, setIdSearchOpen] = useQueryState('idsearch', parseAsBoolean.withDefault(false));
const [idSearchOpen, setIdSearchOpen] = useState(false);
const [searchField, setSearchField] = useState<'name' | 'originalName' | 'type' | 'tags' | 'id'>('name');
const [searchQuery, setSearchQuery] = useReducer(
(state: ReducerQuery['state'], action: ReducerQuery['action']) => {
@@ -503,7 +498,7 @@ export default function FileTable({ id }: { id?: string }) {
</Tooltip>
<Tooltip label='View file in new tab'>
<Link href={`/view/${file.name}`} target='_blank'>
<Link to={`/view/${file.name}`} target='_blank'>
<ActionIcon color='blue'>
<IconExternalLink size='1rem' />
</ActionIcon>

View File

@@ -12,22 +12,19 @@ import {
Title,
} from '@mantine/core';
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { parseAsInteger, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { lazy, Suspense, useEffect, useState } from 'react';
import { useApiPagination } from '../useApiPagination';
import { Link } from 'react-router-dom';
import { useQueryState } from '@/lib/hooks/useQueryState';
const DashboardFile = dynamic(() => import('@/components/file/DashboardFile'), {
loading: () => <Skeleton height={350} animate />,
});
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
export default function Files({ id }: { id?: string }) {
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [perpage, setPerpage] = useState<number>(15);
const [cachedPages, setCachedPages] = useState<number>(1);
const [page, setPage] = useQueryState('page', 1);
const [perpage, setPerpage] = useState(15);
const [cachedPages, setCachedPages] = useState(1);
const { data, isLoading } = useApiPagination({
page,
@@ -60,7 +57,11 @@ export default function Files({ id }: { id?: string }) {
{isLoading ? (
[...Array(9)].map((_, i) => <Skeleton key={i} height={350} animate />)
) : (data?.page?.length ?? 0 > 0) ? (
data?.page.map((file) => <DashboardFile key={file.id} file={file} />)
data?.page.map((file) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} />
</Suspense>
))
) : (
<Paper withBorder p='sm'>
<Center>
@@ -75,7 +76,7 @@ export default function Files({ id }: { id?: string }) {
size='compact-sm'
leftSection={<IconFileUpload size='1rem' />}
component={Link}
href='/dashboard/upload/file'
to='/dashboard/upload/file'
>
Upload a file
</Button>

View File

@@ -1,4 +1,4 @@
import DashboardFile from '@/components/file/DashboardFile';
import { useQueryState } from '@/lib/hooks/useQueryState';
import {
Accordion,
Button,
@@ -8,16 +8,19 @@ import {
Pagination,
Paper,
SimpleGrid,
Skeleton,
Stack,
Title,
} from '@mantine/core';
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
import Link from 'next/link';
import { parseAsInteger, useQueryState } from 'nuqs';
import { Link } from 'react-router-dom';
import { useApiPagination } from '../files/useApiPagination';
import { lazy, Suspense } from 'react';
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
export default function FavoriteFiles() {
const [page, setPage] = useQueryState('fpage', parseAsInteger.withDefault(1));
const [page, setPage] = useQueryState('fpage', 1);
const { data, isLoading } = useApiPagination({
page,
favorite: true,
@@ -47,7 +50,11 @@ export default function FavoriteFiles() {
<LoadingOverlay visible />
</Paper>
) : (data?.page.length ?? 0 > 0) ? (
data?.page.map((file) => <DashboardFile key={file.id} file={file} />)
data?.page.map((file) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} />
</Suspense>
))
) : (
<Paper withBorder p='sm'>
<Center>
@@ -61,7 +68,7 @@ export default function FavoriteFiles() {
size='compact-sm'
leftSection={<IconFileUpload size='1rem' />}
component={Link}
href='/dashboard/upload/file'
to='/dashboard/upload/file'
>
Upload a file
</Button>

View File

@@ -1,7 +1,9 @@
import DashboardFile from '@/components/file/DashboardFile';
import { Folder } from '@/lib/db/models/folder';
import { Alert, Anchor, Button, CopyButton, Group, Modal, SimpleGrid, Text } from '@mantine/core';
import { Alert, Anchor, Button, CopyButton, Group, Modal, SimpleGrid, Skeleton, Text } from '@mantine/core';
import { IconShare } from '@tabler/icons-react';
import { lazy, Suspense } from 'react';
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
export default function ViewFilesModal({
folder,
@@ -56,7 +58,9 @@ export default function ViewFilesModal({
pos='relative'
>
{folder?.files?.map((file) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} key={file.id} />
</Suspense>
))}
</SimpleGrid>
)}

View File

@@ -6,7 +6,7 @@ import { useClipboard } from '@mantine/hooks';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import { IconCheck, IconCopy, IconFolderOff } from '@tabler/icons-react';
import Link from 'next/link';
import { Link } from 'react-router-dom';
import { mutate } from 'swr';
export async function deleteFolder(folder: Folder) {
@@ -30,7 +30,7 @@ export function copyFolderUrl(folder: Folder, clipboard: ReturnType<typeof useCl
notifications.show({
title: 'Copied link',
message: (
<Anchor component={Link} href={`/folder/${folder.id}`}>
<Anchor component={Link} to={`/folder/${folder.id}`}>
{`${window.location.protocol}//${window.location.host}/folder/${folder.id}`}
</Anchor>
),

View File

@@ -7,7 +7,7 @@ import { ActionIcon, Button, Group, Modal, Stack, Switch, TextInput, Title, Tool
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { IconFolderPlus, IconPlus } from '@tabler/icons-react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useState } from 'react';
import { mutate } from 'swr';
import FolderGridView from './views/FolderGridView';
import FolderTableView from './views/FolderTableView';
@@ -15,7 +15,7 @@ import FolderTableView from './views/FolderTableView';
export default function DashboardFolders() {
const view = useViewStore((state) => state.folders);
const [open, setOpen] = useQueryState('cfopen', parseAsBoolean.withDefault(false));
const [open, setOpen] = useState(false);
const form = useForm({
initialValues: {

View File

@@ -6,7 +6,7 @@ import { Anchor } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconCheck, IconCopy, IconTagOff } from '@tabler/icons-react';
import Link from 'next/link';
import { Link } from 'react-router-dom';
import { mutate } from 'swr';
export async function deleteInvite(warnDeletion: boolean, invite: Invite) {
@@ -23,7 +23,7 @@ export function copyInviteUrl(invite: Invite, clipboard: ReturnType<typeof useCl
notifications.show({
title: 'Copied link',
message: (
<Anchor component={Link} href={`/invite/${invite.code}`}>
<Anchor component={Link} to={`/invite/${invite.code}`}>
{`${window.location.protocol}//${window.location.host}/invite/${invite.code}`}
</Anchor>
),

View File

@@ -1,20 +1,20 @@
import GridTableSwitcher from '@/components/GridTableSwitcher';
import { Response } from '@/lib/api/response';
import { Invite } from '@/lib/db/models/invite';
import { fetchApi } from '@/lib/fetchApi';
import { useViewStore } from '@/lib/store/view';
import { ActionIcon, Button, Group, Modal, NumberInput, Select, Stack, Title, Tooltip } from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconTagOff } from '@tabler/icons-react';
import { useState } from 'react';
import { mutate } from 'swr';
import InviteGridView from './views/InviteGridView';
import InviteTableView from './views/InviteTableView';
import { useForm } from '@mantine/form';
import { fetchApi } from '@/lib/fetchApi';
import { Response } from '@/lib/api/response';
import { notifications } from '@mantine/notifications';
import { Invite } from '@/lib/db/models/invite';
import { mutate } from 'swr';
import { parseAsBoolean, useQueryState } from 'nuqs';
export default function DashboardInvites() {
const view = useViewStore((state) => state.invites);
const [open, setOpen] = useQueryState('ciopen', parseAsBoolean.withDefault(false));
const [open, setOpen] = useState(false);
const form = useForm<{
maxUses: number | '';

View File

@@ -1,5 +1,4 @@
import { ActionIcon, Tooltip } from '@mantine/core';
import Link from 'next/link';
import styles from './ExternalAuthButton.module.css';
export default function ExternalAuthButton({
@@ -12,7 +11,7 @@ export default function ExternalAuthButton({
return (
<Tooltip label={`Continue with ${provider}`}>
<ActionIcon
component={Link}
component={'a'}
href={`/api/auth/oauth/${provider.toLowerCase()}`}
color={`${provider.toLowerCase()}.0`}
className={styles.button}

View File

@@ -1,18 +1,17 @@
import { Box, Button, Group, Modal, Paper, SimpleGrid, Text, Title, Tooltip } from '@mantine/core';
import { DatePicker } from '@mantine/dates';
import { IconCalendarSearch, IconCalendarTime } from '@tabler/icons-react';
import dynamic from 'next/dynamic';
import { useEffect, useState } from 'react';
import { lazy, useEffect, useState } from 'react';
import FilesUrlsCountGraph from './parts/FilesUrlsCountGraph';
import { useApiStats } from './useStats';
import { StatsCardsSkeleton } from './parts/StatsCards';
import { StatsTablesSkeleton } from './parts/StatsTables';
import dayjs from 'dayjs';
const StatsCards = dynamic(() => import('./parts/StatsCards'));
const StatsTables = dynamic(() => import('./parts/StatsTables'));
const StorageGraph = dynamic(() => import('./parts/StorageGraph'));
const ViewsGraph = dynamic(() => import('./parts/ViewsGraph'));
const StorageGraph = lazy(() => import('./parts/StorageGraph'));
const ViewsGraph = lazy(() => import('./parts/ViewsGraph'));
const StatsCards = lazy(() => import('./parts/StatsCards'));
const StatsTables = lazy(() => import('./parts/StatsTables'));
export default function DashboardMetrics() {
const today = dayjs();

View File

@@ -1,61 +1,70 @@
import { Response } from '@/lib/api/response';
import { Alert, Anchor, Collapse, Group, SimpleGrid, Skeleton, Stack, Title } from '@mantine/core';
import useSWR from 'swr';
import dynamic from 'next/dynamic';
import { useDisclosure } from '@mantine/hooks';
import Domains from './parts/Domains';
import useSWR from 'swr';
import { lazy, Suspense, useMemo } from 'react';
const Core = lazy(() => import('./parts/Core'));
const Chunks = lazy(() => import('./parts/Chunks'));
const Discord = lazy(() => import('./parts/Discord'));
const Domains = lazy(() => import('./parts/Domains'));
const Features = lazy(() => import('./parts/Features'));
const Files = lazy(() => import('./parts/Files'));
const HttpWebhook = lazy(() => import('./parts/HttpWebhook'));
const Invites = lazy(() => import('./parts/Invites'));
const Mfa = lazy(() => import('./parts/Mfa'));
const Oauth = lazy(() => import('./parts/Oauth'));
const PWA = lazy(() => import('./parts/PWA'));
const Ratelimit = lazy(() => import('./parts/Ratelimit'));
const Tasks = lazy(() => import('./parts/Tasks'));
const Urls = lazy(() => import('./parts/Urls'));
const Website = lazy(() => import('./parts/Website'));
function SettingsSkeleton() {
return <Skeleton height={280} animate />;
return Array(17)
.fill(null)
.map((_, index) => <Skeleton key={index} height={280} animate />);
}
const Core = dynamic(() => import('./parts/Core'), {
loading: () => <SettingsSkeleton />,
});
const Chunks = dynamic(() => import('./parts/Chunks'), {
loading: () => <SettingsSkeleton />,
});
const Discord = dynamic(() => import('./parts/Discord'), {
loading: () => <SettingsSkeleton />,
});
const Features = dynamic(() => import('./parts/Features'), {
loading: () => <SettingsSkeleton />,
});
const Files = dynamic(() => import('./parts/Files'), {
loading: () => <SettingsSkeleton />,
});
const HttpWebhook = dynamic(() => import('./parts/HttpWebhook'), {
loading: () => <SettingsSkeleton />,
});
const Invites = dynamic(() => import('./parts/Invites'), {
loading: () => <SettingsSkeleton />,
});
const Mfa = dynamic(() => import('./parts/Mfa'), {
loading: () => <SettingsSkeleton />,
});
const Oauth = dynamic(() => import('./parts/Oauth'), {
loading: () => <SettingsSkeleton />,
});
const Ratelimit = dynamic(() => import('./parts/Ratelimit'), {
loading: () => <SettingsSkeleton />,
});
const Tasks = dynamic(() => import('./parts/Tasks'), {
loading: () => <SettingsSkeleton />,
});
const Urls = dynamic(() => import('./parts/Urls'), {
loading: () => <SettingsSkeleton />,
});
const Website = dynamic(() => import('./parts/Website'), {
loading: () => <SettingsSkeleton />,
});
const PWA = dynamic(() => import('./parts/PWA'), {
loading: () => <SettingsSkeleton />,
});
export default function DashboardSettings() {
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'>
@@ -75,7 +84,9 @@ export default function DashboardSettings() {
<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>
@@ -86,7 +97,7 @@ export default function DashboardSettings() {
{error ? (
<div>Error loading server settings</div>
) : (
<>
<Suspense fallback={<SettingsSkeleton />}>
<Core swr={{ data, isLoading }} />
<Chunks swr={{ data, isLoading }} />
<Tasks swr={{ data, isLoading }} />
@@ -100,15 +111,16 @@ export default function DashboardSettings() {
</Stack>
<Ratelimit swr={{ data, isLoading }} />
<Stack>
<Website swr={{ data, isLoading }} />
<Oauth swr={{ data, isLoading }} />
<PWA swr={{ data, isLoading }} />
</Stack>
<Oauth swr={{ data, isLoading }} />
<HttpWebhook swr={{ data, isLoading }} />
<Domains swr={{ data, isLoading }} />
</>
</Suspense>
)}
</SimpleGrid>

View File

@@ -2,8 +2,8 @@ import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function Chunks({
@@ -11,7 +11,8 @@ export default function Chunks({
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
chunksEnabled: true,
@@ -26,7 +27,7 @@ export default function Chunks({
}),
});
const onSubmit = settingsOnSubmit(router, form);
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;

View File

@@ -2,8 +2,8 @@ import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function Core({
@@ -11,7 +11,8 @@ export default function Core({
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm<{
coreReturnHttpsUrls: boolean;
coreDefaultDomain: string | null | undefined;
@@ -34,7 +35,7 @@ export default function Core({
values.coreDefaultDomain = values.coreDefaultDomain.trim();
}
return settingsOnSubmit(router, form)(values);
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {

View File

@@ -13,8 +13,8 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
type DiscordEmbed = Record<string, any>;
@@ -24,7 +24,7 @@ export default function Discord({
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const formMain = useForm({
initialValues: {
@@ -44,7 +44,7 @@ export default function Discord({
sendValues.discordAvatarUrl =
values.discordAvatarUrl?.trim() === '' ? null : values.discordAvatarUrl?.trim();
return settingsOnSubmit(router, formMain)(sendValues);
return settingsOnSubmit(navigate, formMain)(sendValues);
};
const formOnUpload = useForm({
@@ -120,7 +120,7 @@ export default function Discord({
};
}
return settingsOnSubmit(router, type === 'upload' ? formOnUpload : formOnShorten)(sendValues);
return settingsOnSubmit(navigate, type === 'upload' ? formOnUpload : formOnShorten)(sendValues);
};
useEffect(() => {

View File

@@ -2,16 +2,20 @@ import { Response } from '@/lib/api/response';
import { Button, Group, LoadingOverlay, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconPlus, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
const DOMAIN_REGEX =
/^[a-zA-Z0-9][a-zA-Z0-9-_]{0,61}[a-zA-Z0-9]{0,1}\.([a-zA-Z]{1,6}|[a-zA-Z0-9-]{1,30}\.[a-zA-Z]{2,3})$/gim;
export default function Domains({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const [domains, setDomains] = useState<string[]>([]);
const form = useForm({
initialValues: {
@@ -19,7 +23,7 @@ export default function Domains({
},
});
const onSubmit = settingsOnSubmit(router, form);
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;
@@ -33,6 +37,10 @@ export default function Domains({
const { newDomain } = form.values;
if (!newDomain) return;
if (!DOMAIN_REGEX.test(newDomain)) {
return form.setFieldError('newDomain', 'Invalid Domain');
}
const updatedDomains = [...domains, newDomain.trim()];
setDomains(updatedDomains);
form.setValues({ newDomain: '' });

View File

@@ -12,8 +12,8 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function Features({
@@ -21,7 +21,8 @@ export default function Features({
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
featuresImageCompression: true,
@@ -43,7 +44,7 @@ export default function Features({
}),
});
const onSubmit = settingsOnSubmit(router, form);
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;

View File

@@ -12,8 +12,8 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function Files({
@@ -21,7 +21,8 @@ export default function Files({
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm<{
filesRoute: string;
filesLength: number;
@@ -79,7 +80,7 @@ export default function Files({
.filter((ext) => ext !== '');
}
return settingsOnSubmit(router, form)(values);
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {

View File

@@ -2,8 +2,8 @@ import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function HttpWebhook({
@@ -11,7 +11,8 @@ export default function HttpWebhook({
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
httpWebhookOnUpload: '',
@@ -33,7 +34,7 @@ export default function HttpWebhook({
}
}
return settingsOnSubmit(router, form)(values);
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {

View File

@@ -2,8 +2,8 @@ import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, NumberInput, Paper, SimpleGrid, Switch, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function Invites({
@@ -11,7 +11,8 @@ export default function Invites({
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
invitesEnabled: true,
@@ -25,7 +26,7 @@ export default function Invites({
}),
});
const onSubmit = settingsOnSubmit(router, form);
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;

View File

@@ -2,8 +2,8 @@ import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function Mfa({
@@ -11,7 +11,8 @@ export default function Mfa({
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
mfaTotpEnabled: false,
@@ -23,7 +24,7 @@ export default function Mfa({
}),
});
const onSubmit = settingsOnSubmit(router, form);
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;

View File

@@ -12,8 +12,8 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function Oauth({
@@ -21,7 +21,8 @@ export default function Oauth({
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
oauthBypassLocalLogin: false,
@@ -85,7 +86,7 @@ export default function Oauth({
}
}
return settingsOnSubmit(router, form)(values);
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {
@@ -147,8 +148,8 @@ export default function Oauth({
{...form.getInputProps('oauthLoginOnly', { type: 'checkbox' })}
/>
</SimpleGrid>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<Paper withBorder p='sm'>
<Paper withBorder p='sm' my='sm'>
<Anchor href='https://discord.com/developers/applications' target='_blank'>
<Title order={4} mb='sm'>
Discord
@@ -173,6 +174,8 @@ export default function Oauth({
{...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'>
@@ -188,16 +191,14 @@ export default function Oauth({
{...form.getInputProps('oauthGoogleRedirectUri')}
/>
</Paper>
</SimpleGrid>
<Paper withBorder p='sm' my='md'>
<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
@@ -205,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>
</SimpleGrid>
<Paper withBorder p='sm' my='md'>
<Title order={4}>OpenID Connect</Title>

View File

@@ -13,8 +13,8 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy, IconRefresh } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function PWA({
@@ -22,7 +22,8 @@ export default function PWA({
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
pwaEnabled: false,
@@ -48,7 +49,7 @@ export default function PWA({
sendValues.pwaDescription = values.pwaDescription.trim() === '' ? null : values.pwaDescription.trim();
return settingsOnSubmit(
router,
navigate,
form,
)({
...sendValues,
@@ -72,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>
@@ -130,7 +131,7 @@ export default function PWA({
<Button type='submit' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
<Button onClick={() => router.reload()} leftSection={<IconRefresh size='1rem' />}>
<Button onClick={() => window.location.reload()} leftSection={<IconRefresh size='1rem' />}>
Refresh
</Button>
</Group>

View File

@@ -12,8 +12,8 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function Ratelimit({
@@ -21,7 +21,8 @@ export default function Ratelimit({
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm<{
ratelimitEnabled: boolean;
ratelimitMax: number;
@@ -61,7 +62,7 @@ export default function Ratelimit({
values.ratelimitWindow = null;
}
return settingsOnSubmit(router, form)(values);
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {
@@ -119,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

@@ -2,8 +2,8 @@ import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Paper, SimpleGrid, Text, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function Tasks({
@@ -11,7 +11,8 @@ export default function Tasks({
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
tasksDeleteInterval: '30m',
@@ -25,7 +26,7 @@ export default function Tasks({
}),
});
const onSubmit = settingsOnSubmit(router, form);
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;

View File

@@ -2,8 +2,8 @@ import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, NumberInput, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function Urls({
@@ -11,7 +11,8 @@ export default function Urls({
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
urlsRoute: '/go',
@@ -22,7 +23,7 @@ export default function Urls({
}),
});
const onSubmit = settingsOnSubmit(router, form);
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;

View File

@@ -2,8 +2,8 @@ import { Response } from '@/lib/api/response';
import { Button, Grid, JsonInput, Paper, Switch, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
const defaultExternalLinks = [
@@ -22,7 +22,8 @@ export default function Website({
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
websiteTitle: 'Zipline',
@@ -72,7 +73,7 @@ export default function Website({
sendValues.websiteLoginBackgroundBlur = values.websiteLoginBackgroundBlur;
return settingsOnSubmit(router, form)(sendValues);
return settingsOnSubmit(navigate, form)(sendValues);
};
useEffect(() => {

View File

@@ -3,12 +3,12 @@ import React from 'react';
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { showNotification } from '@mantine/notifications';
import { NextRouter } from 'next/router';
import { mutate } from 'swr';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useForm } from '@mantine/form';
import { NavigateFunction } from 'react-router-dom';
export function settingsOnSubmit(router: NextRouter, form: ReturnType<typeof useForm<any>>) {
export function settingsOnSubmit(navigate: NavigateFunction, form: ReturnType<typeof useForm<any>>) {
return async (values: unknown) => {
const { data, error } = await fetchApi<Response['/api/server/settings']>(
'/api/server/settings',
@@ -41,7 +41,9 @@ export function settingsOnSubmit(router: NextRouter, form: ReturnType<typeof use
await fetch('/reload');
mutate('/api/server/settings', data);
router.replace(router.asPath, undefined, { scroll: false });
mutate('/api/server/settings/web');
mutate('/api/server/public');
navigate('/dashboard/admin/settings', { replace: true });
}
};
}

View File

@@ -2,22 +2,26 @@ import { useConfig } from '@/components/ConfigProvider';
import { eitherTrue } from '@/lib/primitive';
import { isAdministrator } from '@/lib/role';
import { useUserStore } from '@/lib/store/user';
import { Group, SimpleGrid, Title } from '@mantine/core';
import SettingsAvatar from './parts/SettingsAvatar';
import SettingsDashboard from './parts/SettingsDashboard';
import SettingsFileView from './parts/SettingsFileView';
import SettingsGenerators from './parts/SettingsGenerators';
import SettingsMfa from './parts/SettingsMfa';
import SettingsOAuth from './parts/SettingsOAuth';
import SettingsServerActions from './parts/SettingsServerUtil';
import SettingsUser from './parts/SettingsUser';
import SettingsExports from './parts/SettingsExports';
import SettingsSessions from './parts/SettingsSessions';
import { Group, SimpleGrid, Stack, Title } from '@mantine/core';
import { lazy } from 'react';
const SettingsAvatar = lazy(() => import('./parts/SettingsAvatar'));
const SettingsDashboard = lazy(() => import('./parts/SettingsDashboard'));
const SettingsFileView = lazy(() => import('./parts/SettingsFileView'));
const SettingsGenerators = lazy(() => import('./parts/SettingsGenerators'));
const SettingsMfa = lazy(() => import('./parts/SettingsMfa'));
const SettingsServerActions = lazy(() => import('./parts/SettingsServerUtil'));
const SettingsUser = lazy(() => import('./parts/SettingsUser'));
const SettingsExports = lazy(() => import('./parts/SettingsExports'));
const SettingsSessions = lazy(() => import('./parts/SettingsSessions'));
const SettingsOAuth = lazy(() => import('./parts/SettingsOAuth'));
export default function DashboardSettings() {
const config = useConfig();
const user = useUserStore((state) => state.user);
console.log(config.oauthEnabled);
return (
<>
<Group gap='sm'>
@@ -29,19 +33,24 @@ export default function DashboardSettings() {
<SettingsAvatar />
<Stack gap='sm'>
<SettingsSessions />
{config.features.oauthRegistration && <SettingsOAuth />}
<SettingsDashboard />
</Stack>
<SettingsFileView />
{eitherTrue(
config.oauthEnabled.discord,
config.oauthEnabled.github,
config.oauthEnabled.google,
config.oauthEnabled.oidc,
) && <SettingsOAuth />}
{eitherTrue(config.mfa.totp.enabled, config.mfa.passkeys) && <SettingsMfa />}
<SettingsGenerators />
<SettingsExports />
<SettingsGenerators />
{isAdministrator(user?.role) && <SettingsServerActions />}
</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,10 +1,11 @@
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';
import Link from 'next/link';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
export default function SettingsExports() {
@@ -64,21 +65,18 @@ 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'>
<Paper withBorder p={0} mt='sm'>
<Table highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>ID</Table.Th>
<Table.Th>Started On</Table.Th>
<Table.Th>Started</Table.Th>
<Table.Th>Files</Table.Th>
<Table.Th>Size</Table.Th>
</Table.Tr>
@@ -87,11 +85,24 @@ export default function SettingsExports() {
{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 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>
<Table.Td w={95}>
<Group>
<ActionIcon onClick={() => handleDelete(exportDb.id)}>
<IconTrashFilled size='1rem' />
@@ -100,7 +111,7 @@ export default function SettingsExports() {
<ActionIcon
component={Link}
target='_blank'
href={`/api/user/export?id=${exportDb.id}`}
to={`/api/user/export?id=${exportDb.id}`}
disabled={!exportDb.completed}
>
<IconDownload size='1rem' />
@@ -111,6 +122,7 @@ export default function SettingsExports() {
))}
</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

@@ -13,13 +13,13 @@ import {
Text,
} from '@mantine/core';
import { IconDownload, IconEyeFilled, IconGlobe, IconPercentage, IconWriting } from '@tabler/icons-react';
import Link from 'next/link';
import React, { useReducer, useState } from 'react';
import useSWR from 'swr';
import { flameshot } from './generators/flameshot';
import { sharex } from './generators/sharex';
import { shell } from './generators/shell';
import { ishare } from './generators/ishare';
import { Link } from 'react-router-dom';
export type GeneratorOptions = {
deletesAt: string | null;
@@ -283,7 +283,7 @@ export default function GeneratorButton({
description={
<>
If using a compositor such as{' '}
<Anchor size='xs' component={Link} href='https://github.com/hyprwm/hyprland'>
<Anchor size='xs' component={Link} to='https://github.com/hyprwm/hyprland'>
Hyprland
</Anchor>
, this option will set the <Code>XDG_CURRENT_DESKTOP=sway</Code> to workaround

View File

@@ -1,8 +1,7 @@
import { Anchor, Code, Group, Paper, Text, Title, Image as MantineImage } from '@mantine/core';
import { IconPrompt } from '@tabler/icons-react';
import Image from 'next/image';
import GeneratorButton from './GeneratorButton';
import Link from 'next/link';
import { Link } from 'react-router-dom';
export default function SettingsGenerators() {
return (
@@ -17,35 +16,30 @@ export default function SettingsGenerators() {
<GeneratorButton
name='ShareX'
icon={
<Image width={24} height={24} alt='sharex logo' src='https://getsharex.com/img/ShareX_Logo.svg' />
<img width={24} height={24} alt='sharex logo' src='https://getsharex.com/img/ShareX_Logo.svg' />
}
/>
<GeneratorButton
name='Flameshot'
icon={
<Image
width={24}
height={24}
alt='flameshot logo'
src='https://flameshot.org/flameshot-icon.svg'
/>
<img width={24} height={24} alt='flameshot logo' src='https://flameshot.org/flameshot-icon.svg' />
}
desc={
<>
To use this script, you need{' '}
<Anchor component={Link} href='https://flameshot.org'>
<Anchor component={Link} to='https://flameshot.org'>
Flameshot
</Anchor>
,{' '}
<Anchor component={Link} href='https://curl.se/'>
<Anchor component={Link} to='https://curl.se/'>
<Code>curl</Code>
</Anchor>
,{' '}
<Anchor component={Link} href='https://github.com/stedolan/jq'>
<Anchor component={Link} to='https://github.com/stedolan/jq'>
<Code>jq</Code>
</Anchor>
, and{' '}
<Anchor component={Link} href='https://github.com/astrand/xclip'>
<Anchor component={Link} to='https://github.com/astrand/xclip'>
<Code>xclip</Code> (linux only)
</Anchor>{' '}
installed. This script is intended for use on Linux and macOS only (see options below).
@@ -75,19 +69,19 @@ export default function SettingsGenerators() {
desc={
<>
To use this script, you need <Code>bash</Code>,{' '}
<Anchor component={Link} href='https://curl.se/'>
<Anchor component={Link} to='https://curl.se/'>
<Code>curl</Code>
</Anchor>
,{' '}
<Anchor component={Link} href='https://darwinsys.com/file/'>
<Anchor component={Link} to='https://darwinsys.com/file/'>
<Code>file</Code>
</Anchor>
,{' '}
<Anchor component={Link} href='https://github.com/stedolan/jq'>
<Anchor component={Link} to='https://github.com/stedolan/jq'>
<Code>jq</Code>
</Anchor>
, and{' '}
<Anchor component={Link} href='https://github.com/astrand/xclip'>
<Anchor component={Link} to='https://github.com/astrand/xclip'>
<Code>xclip</Code> (linux only)
</Anchor>{' '}
installed. This script is intended for use on Linux and macOS only (see options below).

View File

@@ -6,7 +6,7 @@ import { RegistrationResponseJSON } from '@github/webauthn-json/dist/types/brows
import { ActionIcon, Button, Group, Modal, Paper, Stack, Text, TextInput } from '@mantine/core';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import { UserPasskey } from '../../../../../../generated/client';
import { UserPasskey } from '@/prisma/client';
import { IconKey, IconKeyOff, IconTrashFilled } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { mutate } from 'swr';

View File

@@ -17,8 +17,8 @@ import {
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconShieldLockFilled } from '@tabler/icons-react';
import Link from 'next/link';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import useSWR, { mutate } from 'swr';
import { useShallow } from 'zustand/shallow';
@@ -129,13 +129,13 @@ export default function TwoFAButton() {
<Text size='sm' c='dimmed'>
<b>Step 1</b> Open/download an authenticator that supports QR code scanning or manual code
entry. Popular options include{' '}
<Anchor component={Link} href='https://2fas.com/' target='_blank'>
<Anchor component={Link} to='https://2fas.com/' target='_blank'>
2FAs
</Anchor>
,{' '}
<Anchor
component={Link}
href='https://support.google.com/accounts/answer/1066447'
to='https://support.google.com/accounts/answer/1066447'
target='_blank'
>
Google Authenticator
@@ -143,7 +143,7 @@ export default function TwoFAButton() {
, and{' '}
<Anchor
component={Link}
href='https://www.microsoft.com/en-us/security/mobile-authenticator-app'
to='https://www.microsoft.com/en-us/security/mobile-authenticator-app'
target='_blank'
>
Microsoft Authenticator

View File

@@ -4,9 +4,9 @@ import { fetchApi } from '@/lib/fetchApi';
import { findProvider } from '@/lib/oauth/providerUtil';
import { useUserStore } from '@/lib/store/user';
import { darken } from '@/lib/theme/color';
import type { OAuthProviderType } from '@/prisma/client';
import { Button, ButtonProps, Paper, SimpleGrid, Text, Title, useMantineTheme } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import type { OAuthProviderType } from '../../../../../../generated/client';
import {
IconBrandDiscordFilled,
IconBrandGithubFilled,
@@ -15,7 +15,6 @@ import {
IconCircleKeyFilled,
IconUserExclamation,
} from '@tabler/icons-react';
import Link from 'next/link';
import { mutate } from 'swr';
import styles from './index.module.css';
@@ -76,7 +75,7 @@ function OAuthButton({ provider, linked }: { provider: OAuthProviderType; linked
Unlink {names[provider]} account
</Button>
) : (
<Button {...baseProps} component={Link} href={`/api/auth/oauth/${provider.toLowerCase()}?state=link`}>
<Button {...baseProps} component={'a'} href={`/api/auth/oauth/${provider.toLowerCase()}?state=link`}>
Link {names[provider]} account
</Button>
);

View File

@@ -4,7 +4,7 @@ import { Button, Paper, SimpleGrid, Skeleton, Text, Title } from '@mantine/core'
import { modals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { IconLogout } from '@tabler/icons-react';
import Link from 'next/link';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
export default function SettingsSessions() {
@@ -61,7 +61,7 @@ export default function SettingsSessions() {
>
Log out everywhere
</Button>
<Button color='yellow' component={Link} href='/auth/logout' leftSection={<IconLogout size='1rem' />}>
<Button color='yellow' component={Link} to='/auth/logout' leftSection={<IconLogout size='1rem' />}>
Log out of this browser
</Button>
</SimpleGrid>

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

@@ -1,4 +1,6 @@
import { useConfig } from '@/components/ConfigProvider';
import { bytes } from '@/lib/bytes';
import { humanizeDuration } from '@/lib/relativeTime';
import { useUploadOptionsStore } from '@/lib/store/uploadOptions';
import {
ActionIcon,
@@ -19,15 +21,13 @@ import { Dropzone } from '@mantine/dropzone';
import { useClipboard, useColorScheme } from '@mantine/hooks';
import { notifications, showNotification } from '@mantine/notifications';
import { IconDeviceSdCard, IconFiles, IconUpload, IconX } from '@tabler/icons-react';
import Link from 'next/link';
import { useEffect, useState, useCallback } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useShallow } from 'zustand/shallow';
import UploadOptionsButton from '../UploadOptionsButton';
import { uploadFiles } from '../uploadFiles';
import ToUploadFile from './ToUploadFile';
import { bytes } from '@/lib/bytes';
import { uploadPartialFiles } from '../uploadPartialFiles';
import { humanizeDuration } from '@/lib/relativeTime';
import { useShallow } from 'zustand/shallow';
import ToUploadFile from './ToUploadFile';
export default function UploadFile({ title, folder }: { title?: string; folder?: string }) {
const theme = useMantineTheme();
@@ -126,6 +126,8 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
};
}, [files.length]);
if (!config) return null;
return (
<>
<Group gap='sm'>
@@ -133,7 +135,7 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
{!folder && (
<Tooltip label='View your files'>
<ActionIcon component={Link} href='/dashboard/files' variant='outline' radius='sm'>
<ActionIcon component={Link} to='/dashboard/files' variant='outline' radius='sm'>
<IconFiles size={18} />
</ActionIcon>
</Tooltip>

View File

@@ -1,6 +1,5 @@
import Render from '@/components/render/Render';
import { useUploadOptionsStore } from '@/lib/store/uploadOptions';
import DashboardUploadText from '@/pages/dashboard/upload/text';
import {
ActionIcon,
Button,
@@ -15,20 +14,17 @@ import {
} from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { IconCursorText, IconEyeFilled, IconFiles, IconUpload } from '@tabler/icons-react';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useShallow } from 'zustand/shallow';
import UploadOptionsButton from '../UploadOptionsButton';
import { renderMode } from '../renderMode';
import { uploadFiles } from '../uploadFiles';
import { useCodeMap } from '@/components/ConfigProvider';
import styles from './index.module.css';
import { useShallow } from 'zustand/shallow';
export default function UploadText({
codeMeta,
}: {
codeMeta: Parameters<typeof DashboardUploadText>[0]['codeMeta'];
}) {
export default function UploadText() {
const clipboard = useClipboard();
const [options, ephemeral, clearEphemeral] = useUploadOptionsStore(
useShallow((state) => [state.options, state.ephemeral, state.clearEphemeral]),
@@ -37,6 +33,8 @@ export default function UploadText({
const [text, setText] = useState('');
const [loading, setLoading] = useState(false);
const codeMap = useCodeMap();
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (text.length > 0) {
@@ -64,9 +62,10 @@ export default function UploadText({
const upload = () => {
const blob = new Blob([text]);
const file = new File([blob], `text.${selectedLanguage}`, {
type: codeMeta.find((meta) => meta.ext === selectedLanguage)?.mime,
type: codeMap.find((meta) => meta.ext === selectedLanguage)?.mime,
lastModified: Date.now(),
});
uploadFiles([file], {
clipboard,
setFiles: () => {},
@@ -84,7 +83,7 @@ export default function UploadText({
<Title order={1}>Upload text</Title>
<Tooltip label='View your files'>
<ActionIcon component={Link} href='/dashboard/files' variant='outline' radius='sm'>
<ActionIcon component={Link} to='/dashboard/files' variant='outline' radius='sm'>
<IconFiles size={18} />
</ActionIcon>
</Tooltip>
@@ -128,7 +127,7 @@ export default function UploadText({
<Select
searchable
defaultValue='txt'
data={codeMeta.map((meta) => ({ value: meta.ext, label: meta.name }))}
data={codeMap.map((meta) => ({ value: meta.ext, label: meta.name }))}
onChange={(value) => setSelectedLanguage(value as string)}
/>
<UploadOptionsButton numFiles={1} />

View File

@@ -30,16 +30,15 @@ import {
IconTrashFilled,
IconWriting,
} from '@tabler/icons-react';
import Link from 'next/link';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
import { useShallow } from 'zustand/shallow';
export default function UploadOptionsButton({ folder, numFiles }: { folder?: string; numFiles: number }) {
const config = useConfig();
const [opened, setOpen] = useQueryState('upopen', parseAsBoolean.withDefault(false));
const [opened, setOpen] = useState(false);
const [options, ephemeral, setOption, setEphemeral, changes, clearEphemeral, clearOptions] =
useUploadOptionsStore(
useShallow((state) => [
@@ -151,7 +150,7 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
) : (
<>
{'You can set a default expiration time in the '}
<Link href='/dashboard/admin/settings'>settings</Link>
<Link to='/dashboard/admin/settings'>settings</Link>
{'.'}
</>
)}

Some files were not shown because too many files have changed in this diff Show More