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 .github
.next
build build
node_modules node_modules
uploads* uploads*

View File

@@ -17,10 +17,8 @@ body:
label: Version label: Version
description: What version (or docker image) of Zipline are you using? description: What version (or docker image) of Zipline are you using?
options: options:
- Latest v4 release (ghcr.io/diced/zipline or ghcr.io/diced/zipline:latest) - Latest release (ghcr.io/diced/zipline or ghcr.io/diced/zipline:latest)
- Latest v4 commit (ghcr.io/diced/zipline:trunk) - Latest commit (ghcr.io/diced/zipline:trunk)
- Latest v3 release (ghcr.io/diced/zipline:v3)
- Latest v3 commit (ghcr.io/diced/zipline:v3-trunk)
- other (provide version in additional info) - other (provide version in additional info)
validations: validations:
required: true required: true
@@ -33,13 +31,14 @@ body:
- Firefox - Firefox
- Chromium-based (Chrome, Edge, Brave, Opera, mobile chrome/chromium based, etc) - Chromium-based (Chrome, Edge, Brave, Opera, mobile chrome/chromium based, etc)
- Safari - Safari
- Chromium-based Mobile (Chrome, Edge, Brave, Android WebView, etc)
- Firefox Mobile - Firefox Mobile
- Safari Mobile - Safari Mobile
- type: textarea - type: textarea
id: zipline-logs id: zipline-logs
attributes: attributes:
label: Zipline Logs 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 render: shell
- type: textarea - type: textarea
id: browser-logs id: browser-logs

View File

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

10
.gitignore vendored
View File

@@ -13,17 +13,14 @@
# testing # testing
/coverage /coverage
# next.js
/.next/
/out/
# production # production
/build build/
# misc # misc
.DS_Store .DS_Store
*.pem *.pem
.idea .idea
.vscode
# debug # debug
npm-debug.log* npm-debug.log*
@@ -38,7 +35,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts
# eslint # eslint
.eslintcache .eslintcache
@@ -52,4 +48,4 @@ next-env.d.ts
uploads*/ uploads*/
*.crt *.crt
*.key *.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 RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
COPY src ./src 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 tsup.config.ts ./tsup.config.ts
COPY tsconfig.json ./tsconfig.json COPY tsconfig.json ./tsconfig.json
COPY mimes.json ./mimes.json COPY mimes.json ./mimes.json
COPY code.json ./code.json COPY code.json ./code.json
COPY vite-env.d.ts ./vite-env.d.ts
ENV NEXT_TELEMETRY_DISABLED=1 \ ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production NODE_ENV=production
@@ -36,12 +42,9 @@ FROM base
COPY --from=deps /zipline/node_modules ./node_modules COPY --from=deps /zipline/node_modules ./node_modules
COPY --from=builder /zipline/build ./build COPY --from=builder /zipline/build ./build
COPY --from=builder /zipline/.next ./.next
COPY --from=builder /zipline/mimes.json ./mimes.json COPY --from=builder /zipline/mimes.json ./mimes.json
COPY --from=builder /zipline/code.json ./code.json COPY --from=builder /zipline/code.json ./code.json
COPY --from=builder /zipline/generated ./generated
RUN pnpm build:prisma RUN pnpm build:prisma

View File

@@ -2,9 +2,9 @@ import unusedImports from 'eslint-plugin-unused-imports';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import prettier from 'eslint-plugin-prettier'; import prettier from 'eslint-plugin-prettier';
import prettierConfig from 'eslint-config-prettier'; import prettierConfig from 'eslint-config-prettier';
import nextConfig from '@next/eslint-plugin-next';
import reactHooksPlugin from 'eslint-plugin-react-hooks'; import reactHooksPlugin from 'eslint-plugin-react-hooks';
import reactPlugin from 'eslint-plugin-react'; import reactPlugin from 'eslint-plugin-react';
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y'; import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -23,7 +23,13 @@ const gitignorePatterns = gitignoreContent
export default tseslint.config( export default tseslint.config(
{ ignores: gitignorePatterns }, { ignores: gitignorePatterns },
...tseslint.configs.recommended, {
extends: [
tseslint.configs.recommended,
reactHooksPlugin.configs['recommended-latest'],
reactRefreshPlugin.configs.vite,
],
},
{ {
files: ['**/*.{js,mjs,cjs,ts,tsx}'], files: ['**/*.{js,mjs,cjs,ts,tsx}'],
@@ -39,19 +45,12 @@ export default tseslint.config(
plugins: { plugins: {
'unused-imports': unusedImports, 'unused-imports': unusedImports,
prettier: prettier, prettier: prettier,
'@next/next': nextConfig,
'react-hooks': reactHooksPlugin,
react: reactPlugin, react: reactPlugin,
'jsx-a11y': jsxA11yPlugin, 'jsx-a11y': jsxA11yPlugin,
}, },
rules: { rules: {
...reactPlugin.configs.recommended.rules, ...reactPlugin.configs.recommended.rules,
...reactHooksPlugin.configs.recommended.rules,
...nextConfig.configs.recommended.rules,
...nextConfig.configs['core-web-vitals'].rules,
...prettierConfig.rules, ...prettierConfig.rules,
'prettier/prettier': [ 'prettier/prettier': [
'error', 'error',
@@ -60,7 +59,6 @@ export default tseslint.config(
fileInfoOptions: { fileInfoOptions: {
withNodeModules: false, withNodeModules: false,
}, },
ignoreFileExtensions: ['pnpm-lock.yaml'],
}, },
], ],
@@ -78,6 +76,7 @@ export default tseslint.config(
'react/prop-types': 'off', 'react/prop-types': 'off',
'react-hooks/rules-of-hooks': 'off', 'react-hooks/rules-of-hooks': 'off',
'react-hooks/exhaustive-deps': 'off', 'react-hooks/exhaustive-deps': 'off',
'react-refresh/only-export-components': 'off',
'react/jsx-uses-react': 'warn', 'react/jsx-uses-react': 'warn',
'react/jsx-uses-vars': 'warn', 'react/jsx-uses-vars': 'warn',
'react/no-danger-with-children': 'warn', 'react/no-danger-with-children': 'warn',
@@ -110,9 +109,6 @@ export default tseslint.config(
react: { react: {
version: 'detect', 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", "license": "MIT",
"version": "4.2.3", "version": "4.2.3",
"scripts": { "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:prisma": "prisma generate --no-hints",
"build:next": "ZIPLINE_BUILD=true next build",
"build:server": "tsup", "build:server": "tsup",
"dev": "cross-env TURBOPACK=1 NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server", "build:client": "vite build && pnpm run --stream \"/^build-ssr:.*/\"",
"dev:nd": "cross-env TURBOPACK=1 NODE_ENV=development tsx --require dotenv/config --enable-source-maps ./src/server", "build-ssr:view": "vite build --ssr ssr-view/server.tsx -m ssr-view --outDir ../../build/ssr --emptyOutDir=false",
"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", "build-ssr:view-url": "vite build --ssr ssr-view-url/server.tsx -m ssr-view-url --outDir ../../build/ssr --emptyOutDir=false",
"start": "cross-env NODE_ENV=production node --trace-warnings --require dotenv/config --enable-source-maps ./build/server", "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", "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", "ctl": "NODE_ENV=production node --require dotenv/config --enable-source-maps ./build/ctl",
"validate": "pnpm run --stream \"/^validate:.*/\"", "validate": "pnpm run --stream \"/^validate:.*/\"",
@@ -55,6 +60,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"colorette": "^2.0.20", "colorette": "^2.0.20",
"commander": "^14.0.0", "commander": "^14.0.0",
"cookie": "^1.0.2",
"cross-env": "^10.0.0", "cross-env": "^10.0.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
@@ -70,45 +76,45 @@
"mantine-datatable": "^8.2.0", "mantine-datatable": "^8.2.0",
"ms": "^2.1.3", "ms": "^2.1.3",
"multer": "2.0.2", "multer": "2.0.2",
"next": "^15.4.5",
"nuqs": "^2.4.3",
"otplib": "^12.0.1", "otplib": "^12.0.1",
"prisma": "^6.13.0", "prisma": "^6.13.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.7.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sharp": "^0.34.3", "sharp": "^0.34.3",
"swr": "^2.3.4", "swr": "^2.3.4",
"typescript-eslint": "^8.38.0", "typescript-eslint": "^8.39.0",
"vite": "^7.1.0",
"zod": "^3.25.67", "zod": "^3.25.67",
"zustand": "^5.0.7" "zustand": "^5.0.7"
}, },
"devDependencies": { "devDependencies": {
"@next/eslint-plugin-next": "^15.4.5",
"@types/bytes": "^3.1.5", "@types/bytes": "^3.1.5",
"@types/fluent-ffmpeg": "^2.1.27", "@types/fluent-ffmpeg": "^2.1.27",
"@types/katex": "^0.16.7", "@types/katex": "^0.16.7",
"@types/ms": "^2.1.0", "@types/ms": "^2.1.0",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/node": "^24.1.0", "@types/node": "^24.2.0",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/react": "^19.1.9", "@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.32.0", "eslint": "^9.32.0",
"eslint-config-next": "^15.4.5",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2", "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": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^4.1.4",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"sass": "^1.89.2", "sass": "^1.90.0",
"tsc-alias": "^1.8.16", "tsc-alias": "^1.8.16",
"tsup": "^8.5.0", "tsup": "^8.5.0",
"tsx": "^4.20.3", "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 { generator client {
provider = "prisma-client-js" provider = "prisma-client"
output = "../generated/client" output = "../src/prisma"
moduleFormat = "cjs"
previewFeatures = ["queryCompiler", "driverAdapters"] 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 { Button, Center, Stack, Text, Title } from '@mantine/core';
import { IconArrowLeft } from '@tabler/icons-react'; import { IconArrowLeft } from '@tabler/icons-react';
import Link from 'next/link'; import { Link } from 'react-router-dom';
export default function FourOhFour() { export default function FourOhFour() {
return ( return (
@@ -11,12 +11,16 @@ export default function FourOhFour() {
Page not found Page not found
</Text> </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 Go home
</Button> </Button>
</Stack> </Stack>
</Center> </Center>
); );
} }
FourOhFour.title = '404';

View File

@@ -1,11 +1,8 @@
import ExternalAuthButton from '@/components/pages/login/ExternalAuthButton'; import ExternalAuthButton from '@/components/pages/login/ExternalAuthButton';
import { Response } from '@/lib/api/response'; 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 { fetchApi } from '@/lib/fetchApi';
import { withSafeConfig } from '@/lib/middleware/next/withSafeConfig'; import useLogin from '@/lib/hooks/useLogin';
import { authenticateWeb } from '@/lib/passkey'; import { authenticateWeb } from '@/lib/passkey';
import { eitherTrue } from '@/lib/primitive';
import { import {
Button, Button,
Center, Center,
@@ -34,28 +31,43 @@ import {
IconUserPlus, IconUserPlus,
IconX, IconX,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { InferGetServerSidePropsType } from 'next';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, redirect, useLocation, useNavigate } from 'react-router-dom';
import useSWR from 'swr'; import useSWR from 'swr';
import GenericError from '../../error/GenericError';
import { useTitle } from '@/lib/hooks/useTitle';
export default function Login({ config }: InferGetServerSidePropsType<typeof getServerSideProps>) { export default function Login() {
const router = useRouter(); useTitle('Login');
const { data, isLoading, mutate } = useSWR<Response['/api/user']>('/api/user', {
refreshInterval: 120000, 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 = 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 = const willRedirect =
config.oauth.bypassLocalLogin && config?.oauth?.bypassLocalLogin &&
Object.values(config.oauthEnabled).filter((x) => x === true).length === 1 && Object.values(config?.oauthEnabled ?? {}).filter((x) => x === true).length === 1 &&
router.query.local !== 'true'; query.get('local') !== 'true';
const [totpOpen, setTotpOpen] = useState(false); const [totpOpen, setTotpOpen] = useState(false);
const [pinDisabled, setPinDisabled] = 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 [passkeyErrored, setPasskeyErrored] = useState(false);
const [passkeyLoading, setPasskeyLoading] = useState(false); const [passkeyLoading, setPasskeyLoading] = useState(false);
useEffect(() => {
if (data?.user) {
router.push('/dashboard');
}
}, [data]);
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
username: '', username: '',
@@ -123,7 +129,6 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
try { try {
setPasskeyLoading(true); setPasskeyLoading(true);
const res = await authenticateWeb(); const res = await authenticateWeb();
const { data, error } = await fetchApi<Response['/api/auth/webauthn']>('/api/auth/webauthn', 'POST', { const { data, error } = await fetchApi<Response['/api/auth/webauthn']>('/api/auth/webauthn', 'POST', {
auth: res.toJSON(), auth: res.toJSON(),
}); });
@@ -146,16 +151,22 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
}; };
useEffect(() => { useEffect(() => {
if (willRedirect) { if (user) {
navigate('/dashboard');
}
}, [user]);
useEffect(() => {
if (willRedirect && config) {
const provider = Object.keys(config.oauthEnabled).find( 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) { if (provider) {
router.push(`/api/auth/oauth/${provider}`); redirect(`/api/auth/oauth/${provider.toLowerCase()}`);
} }
} }
}, []); }, [willRedirect, config]);
useEffect(() => { useEffect(() => {
if (passkeyErrored) { if (passkeyErrored) {
@@ -172,6 +183,23 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
} }
}, [passkeyErrored]); }, [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 ( return (
<> <>
{willRedirect && !showLocalLogin && <LoadingOverlay visible />} {willRedirect && !showLocalLogin && <LoadingOverlay visible />}
@@ -255,7 +283,10 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
ta='center' ta='center'
style={{ style={{
whiteSpace: 'normal', 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> <b>{config.website.title ?? 'Zipline'}</b>
@@ -263,47 +294,45 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
</div> </div>
{showLocalLogin && ( {showLocalLogin && (
<> <form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}> <Stack my='sm'>
<Stack my='sm'> <TextInput
<TextInput size='md'
size='md' placeholder='Enter your username...'
placeholder='Enter your username...' styles={{
styles={{ input: {
input: { backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
backgroundColor: config.website.loginBackground ? 'transparent' : undefined, },
}, }}
}} {...form.getInputProps('username', { withError: true })}
{...form.getInputProps('username', { withError: true })} />
/>
<PasswordInput <PasswordInput
size='md' size='md'
placeholder='Enter your password...' placeholder='Enter your password...'
styles={{ styles={{
input: { input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined, backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
}, },
}} }}
{...form.getInputProps('password')} {...form.getInputProps('password')}
/> />
<Button <Button
size='md' size='md'
fullWidth fullWidth
type='submit' type='submit'
loading={isLoading} loading={!config}
variant={config.website.loginBackground ? 'outline' : 'filled'} variant={config.website.loginBackground ? 'outline' : 'filled'}
> >
Login Login
</Button> </Button>
</Stack> </Stack>
</form> </form>
</>
)} )}
<Stack my='xs'> <Stack my='xs'>
{eitherTrue(config.features.oauthRegistration, config.features.userRegistration) && ( {(config.features.oauthRegistration || config.features.userRegistration) && (
<Divider label='or' /> <Divider label='or' />
)} )}
@@ -324,7 +353,7 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
{config.features.userRegistration && ( {config.features.userRegistration && (
<Button <Button
component={Link} component={Link}
href='/auth/register' to='/auth/register'
size='md' size='md'
fullWidth fullWidth
variant='outline' variant='outline'
@@ -333,6 +362,7 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
Sign up Sign up
</Button> </Button>
)} )}
<Group grow> <Group grow>
{config.oauthEnabled.discord && ( {config.oauthEnabled.discord && (
<ExternalAuthButton <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 { useUserStore } from '@/lib/store/user';
import { LoadingOverlay } from '@mantine/core'; import { LoadingOverlay } from '@mantine/core';
import { useRouter } from 'next/router';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { mutate } from 'swr'; import { mutate } from 'swr';
export default function Logout() { export default function Logout() {
const router = useRouter(); useTitle('Log out');
const setUser = useUserStore((state) => state.setUser); const setUser = useUserStore((state) => state.setUser);
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const userRes = await fetch('/api/user'); const userRes = await fetch('/api/user');
if (userRes.ok) { if (userRes.ok) {
const res = await fetch('/api/auth/logout'); const res = await fetch('/api/auth/logout');
if (res.ok) { if (res.ok) {
setUser(null); setUser(null);
mutate('/api/user', null); mutate('/api/user', null);
await router.push('/auth/login'); navigate('/auth/login');
} else {
navigate('/dashboard');
} }
} else { } else {
await router.push('/dashboard'); navigate('/dashboard');
} }
})(); })();
}, []); }, []);
return ( return <LoadingOverlay visible />;
<>
<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 { type Response } from '@/lib/api/response';
import { getZipline } from '@/lib/db/models/zipline';
import { fetchApi } from '@/lib/fetchApi'; import { fetchApi } from '@/lib/fetchApi';
import { useTitle } from '@/lib/hooks/useTitle';
import { import {
Anchor, Anchor,
Button, Button,
@@ -18,11 +18,8 @@ import {
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { IconArrowBackUp, IconArrowForwardUp, IconCheck, IconX } from '@tabler/icons-react'; 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 { useState } from 'react';
import { redirect, useNavigate } from 'react-router-dom';
import { mutate } from 'swr'; import { mutate } from 'swr';
function LinkToDoc({ href, title, children }: { href: string; title: string; children: React.ReactNode }) { 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() { export async function loader() {
const router = useRouter(); 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 [active, setActive] = useState(0);
const nextStep = () => setActive((current) => (current < 3 ? current + 1 : current)); const nextStep = () => setActive((current) => (current < 3 ? current + 1 : current));
@@ -99,18 +110,13 @@ export default function Setup() {
setActive(2); setActive(2);
} else { } else {
mutate('/api/user', data as Response['/api/user']); mutate('/api/user', data as Response['/api/user']);
router.push('/dashboard'); navigate('/dashboard');
} }
} }
}; };
return ( return (
<> <>
<Head>
<title>Zipline Setup</title>
<meta name='viewport' content='width=device-width, initial-scale=1' />
</Head>
<Paper withBorder p='xs' m='sm'> <Paper withBorder p='xs' m='sm'>
<Stepper active={active} onStepClick={setActive} m='md'> <Stepper active={active} onStepClick={setActive} m='md'>
<Stepper.Step label='Welcome!' description='Setup Zipline'> <Stepper.Step label='Welcome!' description='Setup Zipline'>
@@ -145,7 +151,11 @@ export default function Setup() {
<Text> <Text>
To see all of the available environment variables, please refer to the documentation{' '} 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. here.
</Anchor> </Anchor>
</Text> </Text>
@@ -236,20 +246,4 @@ export default function Setup() {
); );
} }
export const getServerSideProps: GetServerSideProps = async () => { Component.displayName = 'Setup';
const { firstSetup } = await getZipline();
if (!firstSetup)
return {
redirect: {
destination: '/dashboard',
permanent: false,
},
};
return {
props: {},
};
};
Setup.title = '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'; import { createContext, useContext } from 'react';
const ConfigContext = createContext<SafeConfig | null>(null); type ConfigContextType = ApiServerSettingsWebResponse;
const ConfigContext = createContext<ConfigContextType | null>(null);
export function useConfig() { export function useConfig() {
const ctx = useContext(ConfigContext); const ctx = useContext(ConfigContext);
if (!ctx) throw new Error('useConfig must be used within a ConfigProvider'); 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({ export default function ConfigProvider({
config, data,
children, children,
}: { }: {
config: SafeConfig; data: ConfigContextType;
children: React.ReactNode; 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 { fetchApi } from '@/lib/fetchApi';
import useAvatar from '@/lib/hooks/useAvatar'; import useAvatar from '@/lib/hooks/useAvatar';
import useLogin from '@/lib/hooks/useLogin'; import useLogin from '@/lib/hooks/useLogin';
import { Outlet, useLocation } from 'react-router-dom';
import { isAdministrator } from '@/lib/role'; import { isAdministrator } from '@/lib/role';
import { useUserStore } from '@/lib/store/user'; import { useUserStore } from '@/lib/store/user';
import { import {
@@ -44,11 +45,11 @@ import {
IconUpload, IconUpload,
IconUsersGroup, IconUsersGroup,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import ConfigProvider from './ConfigProvider'; import ConfigProvider from './ConfigProvider';
import VersionBadge from './VersionBadge'; import VersionBadge from './VersionBadge';
import { Link, useLoaderData } from 'react-router-dom';
import { dashboardLoader } from '../client/routes';
type NavLinks = { type NavLinks = {
label: string; 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 theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme(); const { colorScheme } = useMantineColorScheme();
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const router = useRouter();
const modals = useModals(); const modals = useModals();
const clipboard = useClipboard(); const clipboard = useClipboard();
const setUser = useUserStore((s) => s.setUser); const setUser = useUserStore((s) => s.setUser);
const location = useLocation();
const loaderData = useLoaderData<typeof dashboardLoader>();
const config = loaderData.config;
const { user, mutate } = useLogin(); const { user, mutate } = useLogin();
const { avatar } = useAvatar(); const { avatar } = useAvatar();
@@ -275,7 +279,8 @@ export default function Layout({ children, config }: { children: React.ReactNode
<Menu.Item <Menu.Item
leftSection={<IconSettingsFilled size='1rem' />} leftSection={<IconSettingsFilled size='1rem' />}
component={Link} component={Link}
href='/dashboard/settings' to='/dashboard/settings'
prefetch='intent'
> >
Settings Settings
</Menu.Item> </Menu.Item>
@@ -284,7 +289,8 @@ export default function Layout({ children, config }: { children: React.ReactNode
<Menu.Item <Menu.Item
leftSection={<IconAdjustments size='1rem' />} leftSection={<IconAdjustments size='1rem' />}
component={Link} component={Link}
href='/dashboard/admin/settings' to='/dashboard/admin/settings'
prefetch='intent'
> >
Server Settings Server Settings
</Menu.Item> </Menu.Item>
@@ -295,7 +301,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
color='red' color='red'
leftSection={<IconLogout size='1rem' />} leftSection={<IconLogout size='1rem' />}
component={Link} component={Link}
href='/auth/logout' to='/auth/logout'
> >
Logout Logout
</Menu.Item> </Menu.Item>
@@ -322,9 +328,10 @@ export default function Layout({ children, config }: { children: React.ReactNode
leftSection={link.icon} leftSection={link.icon}
variant='light' variant='light'
rightSection={<IconChevronRight size='0.7rem' />} rightSection={<IconChevronRight size='0.7rem' />}
active={router.pathname === link.href} active={location.pathname === link.href}
component={Link} component={Link}
href={link.href || ''} to={link.href || ''}
prefetch='intent'
/> />
); );
} else { } else {
@@ -335,7 +342,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
leftSection={link.icon} leftSection={link.icon}
variant='light' variant='light'
rightSection={<IconChevronRight size='0.7rem' />} rightSection={<IconChevronRight size='0.7rem' />}
defaultOpened={link.active(router.pathname)} defaultOpened={link.active(location.pathname)}
> >
{link.links {link.links
.filter( .filter(
@@ -348,9 +355,10 @@ export default function Layout({ children, config }: { children: React.ReactNode
leftSection={sublink.icon} leftSection={sublink.icon}
rightSection={<IconChevronRight size='0.7rem' />} rightSection={<IconChevronRight size='0.7rem' />}
variant='light' variant='light'
active={router.pathname === sublink.href} active={location.pathname === sublink.href}
component={Link} component={Link}
href={sublink.href || ''} to={sublink.href || ''}
prefetch='intent'
/> />
))} ))}
</NavLink> </NavLink>
@@ -372,7 +380,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
leftSection={<IconExternalLink size='1rem' />} leftSection={<IconExternalLink size='1rem' />}
variant='light' variant='light'
component={Link} component={Link}
href={url} to={url}
target='_blank' target='_blank'
/> />
))} ))}
@@ -382,9 +390,9 @@ export default function Layout({ children, config }: { children: React.ReactNode
</AppShell.Navbar> </AppShell.Navbar>
<AppShell.Main> <AppShell.Main>
<ConfigProvider config={config}> <ConfigProvider data={loaderData}>
<Paper m='lg' withBorder p='xs'> <Paper m='lg' withBorder p='xs'>
{children} <Outlet />
</Paper> </Paper>
</ConfigProvider> </ConfigProvider>
</AppShell.Main> </AppShell.Main>

View File

@@ -1,3 +1,4 @@
import { Response } from '@/lib/api/response';
import { Config } from '@/lib/config/validate'; import { Config } from '@/lib/config/validate';
import { useSettingsStore } from '@/lib/store/settings'; import { useSettingsStore } from '@/lib/store/settings';
import { useUserStore } from '@/lib/store/user'; 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 { MantineProvider, createTheme } from '@mantine/core';
import { useColorScheme } from '@mantine/hooks'; import { useColorScheme } from '@mantine/hooks';
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
import useSWR from 'swr';
import { useShallow } from 'zustand/shallow'; import { useShallow } from 'zustand/shallow';
const ThemeContext = createContext<{ const ThemeContext = createContext<{
@@ -21,15 +23,25 @@ export function useThemes() {
return ctx.themes; return ctx.themes;
} }
export default function Theming({ export default function ThemeProvider({
themes, ssrThemes,
defaultTheme, ssrDefaultTheme,
children, children,
}: { }: {
themes: ZiplineTheme[]; ssrThemes?: ZiplineTheme[];
ssrDefaultTheme?: Config['website']['theme'];
children: React.ReactNode; 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 user = useUserStore((state) => state.user);
const [userTheme, preferredDark, preferredLight] = useSettingsStore( const [userTheme, preferredDark, preferredLight] = useSettingsStore(
useShallow((state) => [state.settings.theme, state.settings.themeDark, state.settings.themeLight]), useShallow((state) => [state.settings.theme, state.settings.themeDark, state.settings.themeLight]),
@@ -53,7 +65,7 @@ export default function Theming({
} }
return ( return (
<ThemeContext.Provider value={{ themes }}> <ThemeContext.Provider value={{ themes: themes ?? [] }}>
<MantineProvider <MantineProvider
defaultColorScheme={theme.colorScheme as unknown as any} defaultColorScheme={theme.colorScheme as unknown as any}
forceColorScheme={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 { renderMode } from '../pages/upload/renderMode';
import Render from '../render/Render'; import Render from '../render/Render';
import fileIcon from './fileIcon'; import fileIcon from './fileIcon';
import { parseAsStringLiteral, useQueryState } from 'nuqs';
function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) { function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
return ( return (
@@ -78,8 +77,6 @@ export default function DashboardFileType({
code?: boolean; code?: boolean;
allowZoom?: boolean; allowZoom?: boolean;
}) { }) {
const [overrideType] = useQueryState('otype', parseAsStringLiteral(['video', 'audio', 'image', 'text']));
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview); const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
const dbFile = 'id' in file; const dbFile = 'id' in file;
@@ -135,7 +132,7 @@ export default function DashboardFileType({
if (code) { if (code) {
setType('text'); setType('text');
getText(); getText();
} else if (overrideType === 'text' || type === 'text') { } else if (type === 'text') {
getText(); getText();
} else { } else {
return; return;
@@ -167,7 +164,7 @@ export default function DashboardFileType({
</Paper> </Paper>
); );
switch (overrideType || type) { switch (type) {
case 'video': case 'video':
return show ? ( return show ? (
<video <video

View File

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

View File

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

View File

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

View File

@@ -6,10 +6,10 @@ import FileTable from './views/FileTable';
import Files from './views/Files'; import Files from './views/Files';
import TagsButton from './tags/TagsButton'; import TagsButton from './tags/TagsButton';
import PendingFilesButton from './PendingFilesButton'; import PendingFilesButton from './PendingFilesButton';
import Link from 'next/link';
import { IconFileUpload } from '@tabler/icons-react'; 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); const view = useViewStore((state) => state.files);
return ( return (
@@ -18,7 +18,7 @@ export default function DashbaordFiles() {
<Title>Files</Title> <Title>Files</Title>
<Tooltip label='Upload a file'> <Tooltip label='Upload a file'>
<Link href='/dashboard/upload/file'> <Link to='/dashboard/upload/file'>
<ActionIcon variant='outline'> <ActionIcon variant='outline'>
<IconFileUpload size='1rem' /> <IconFileUpload size='1rem' />
</ActionIcon> </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 { ActionIcon, Group, Modal, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import { IconPencil, IconPlus, IconTagOff, IconTags, IconTrashFilled } from '@tabler/icons-react'; import { IconPencil, IconPlus, IconTagOff, IconTags, IconTrashFilled } from '@tabler/icons-react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useState } from 'react'; import { useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import CreateTagModal from './CreateTagModal'; import CreateTagModal from './CreateTagModal';
@@ -13,8 +12,8 @@ import EditTagModal from './EditTagModal';
import TagPill from './TagPill'; import TagPill from './TagPill';
export default function TagsButton() { export default function TagsButton() {
const [open, setOpen] = useQueryState('topen', parseAsBoolean.withDefault(false)); const [open, setOpen] = useState(false);
const [createModalOpen, setCreateModalOpen] = useQueryState('ctopen', parseAsBoolean.withDefault(false)); const [createModalOpen, setCreateModalOpen] = useState(false);
const [selectedTag, setSelectedTag] = useState<Tag | null>(null); const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
const { data: tags, mutate } = useSWR<Extract<Tag[], Response['/api/user/tags']>>('/api/user/tags'); 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 { import {
Accordion, Accordion,
Button, Button,
@@ -12,17 +13,14 @@ import {
Title, Title,
} from '@mantine/core'; } from '@mantine/core';
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react'; import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
import dynamic from 'next/dynamic'; import { Link } from 'react-router-dom';
import Link from 'next/link';
import { parseAsInteger, useQueryState } from 'nuqs';
import { useApiPagination } from '../useApiPagination'; import { useApiPagination } from '../useApiPagination';
import { lazy, Suspense } from 'react';
const DashboardFile = dynamic(() => import('@/components/file/DashboardFile'), { const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
loading: () => <Skeleton height={350} animate />,
});
export default function FavoriteFiles() { export default function FavoriteFiles() {
const [page, setPage] = useQueryState('fpage', parseAsInteger.withDefault(1)); const [page, setPage] = useQueryState('fpage', 1);
const { data, isLoading } = useApiPagination({ const { data, isLoading } = useApiPagination({
page, page,
@@ -55,7 +53,11 @@ export default function FavoriteFiles() {
<LoadingOverlay visible /> <LoadingOverlay visible />
</Paper> </Paper>
) : (data?.page.length ?? 0 > 0) ? ( ) : (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'> <Paper withBorder p='sm'>
<Center> <Center>
@@ -69,7 +71,7 @@ export default function FavoriteFiles() {
size='compact-sm' size='compact-sm'
leftSection={<IconFileUpload size='1rem' />} leftSection={<IconFileUpload size='1rem' />}
component={Link} component={Link}
href='/dashboard/upload/file' to='/dashboard/upload/file'
> >
Upload a file Upload a file
</Button> </Button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,61 +1,70 @@
import { Response } from '@/lib/api/response'; import { Response } from '@/lib/api/response';
import { Alert, Anchor, Collapse, Group, SimpleGrid, Skeleton, Stack, Title } from '@mantine/core'; 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 { 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() { 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'), { export default function DashboardServerSettings() {
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() {
const { data, isLoading, error } = useSWR<Response['/api/server/settings']>('/api/server/settings'); const { data, isLoading, error } = useSWR<Response['/api/server/settings']>('/api/server/settings');
const [opened, { toggle }] = useDisclosure(false); 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 ( return (
<> <>
<Group gap='sm'> <Group gap='sm'>
@@ -75,7 +84,9 @@ export default function DashboardSettings() {
<Collapse in={opened} transitionDuration={200}> <Collapse in={opened} transitionDuration={200}>
<ul> <ul>
{data!.tampered.map((setting) => ( {data!.tampered.map((setting) => (
<li key={setting}>{setting}</li> <li key={setting}>
<Anchor onClick={(e) => onTamperedClick(e, setting)}>{setting}</Anchor>
</li>
))} ))}
</ul> </ul>
</Collapse> </Collapse>
@@ -86,7 +97,7 @@ export default function DashboardSettings() {
{error ? ( {error ? (
<div>Error loading server settings</div> <div>Error loading server settings</div>
) : ( ) : (
<> <Suspense fallback={<SettingsSkeleton />}>
<Core swr={{ data, isLoading }} /> <Core swr={{ data, isLoading }} />
<Chunks swr={{ data, isLoading }} /> <Chunks swr={{ data, isLoading }} />
<Tasks swr={{ data, isLoading }} /> <Tasks swr={{ data, isLoading }} />
@@ -100,15 +111,16 @@ export default function DashboardSettings() {
</Stack> </Stack>
<Ratelimit swr={{ data, isLoading }} /> <Ratelimit swr={{ data, isLoading }} />
<Website swr={{ data, isLoading }} /> <Stack>
<Website swr={{ data, isLoading }} />
<PWA swr={{ data, isLoading }} />
</Stack>
<Oauth swr={{ data, isLoading }} /> <Oauth swr={{ data, isLoading }} />
<PWA swr={{ data, isLoading }} />
<HttpWebhook swr={{ data, isLoading }} /> <HttpWebhook swr={{ data, isLoading }} />
<Domains swr={{ data, isLoading }} /> <Domains swr={{ data, isLoading }} />
</> </Suspense>
)} )}
</SimpleGrid> </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 { Button, LoadingOverlay, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react'; import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit'; import { settingsOnSubmit } from '../settingsOnSubmit';
export default function Chunks({ export default function Chunks({
@@ -11,7 +11,8 @@ export default function Chunks({
}: { }: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean }; swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) { }) {
const router = useRouter(); const navigate = useNavigate();
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
chunksEnabled: true, chunksEnabled: true,
@@ -26,7 +27,7 @@ export default function Chunks({
}), }),
}); });
const onSubmit = settingsOnSubmit(router, form); const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => { useEffect(() => {
if (!data) return; 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 { Button, LoadingOverlay, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react'; import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit'; import { settingsOnSubmit } from '../settingsOnSubmit';
export default function Core({ export default function Core({
@@ -11,7 +11,8 @@ export default function Core({
}: { }: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean }; swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) { }) {
const router = useRouter(); const navigate = useNavigate();
const form = useForm<{ const form = useForm<{
coreReturnHttpsUrls: boolean; coreReturnHttpsUrls: boolean;
coreDefaultDomain: string | null | undefined; coreDefaultDomain: string | null | undefined;
@@ -34,7 +35,7 @@ export default function Core({
values.coreDefaultDomain = values.coreDefaultDomain.trim(); values.coreDefaultDomain = values.coreDefaultDomain.trim();
} }
return settingsOnSubmit(router, form)(values); return settingsOnSubmit(navigate, form)(values);
}; };
useEffect(() => { useEffect(() => {

View File

@@ -13,8 +13,8 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react'; import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit'; import { settingsOnSubmit } from '../settingsOnSubmit';
type DiscordEmbed = Record<string, any>; type DiscordEmbed = Record<string, any>;
@@ -24,7 +24,7 @@ export default function Discord({
}: { }: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean }; swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) { }) {
const router = useRouter(); const navigate = useNavigate();
const formMain = useForm({ const formMain = useForm({
initialValues: { initialValues: {
@@ -44,7 +44,7 @@ export default function Discord({
sendValues.discordAvatarUrl = sendValues.discordAvatarUrl =
values.discordAvatarUrl?.trim() === '' ? null : values.discordAvatarUrl?.trim(); values.discordAvatarUrl?.trim() === '' ? null : values.discordAvatarUrl?.trim();
return settingsOnSubmit(router, formMain)(sendValues); return settingsOnSubmit(navigate, formMain)(sendValues);
}; };
const formOnUpload = useForm({ 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(() => { 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 { Button, Group, LoadingOverlay, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { IconPlus, IconTrash } from '@tabler/icons-react'; import { IconPlus, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit'; 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({ export default function Domains({
swr: { data, isLoading }, swr: { data, isLoading },
}: { }: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean }; swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) { }) {
const router = useRouter(); const navigate = useNavigate();
const [domains, setDomains] = useState<string[]>([]); const [domains, setDomains] = useState<string[]>([]);
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
@@ -19,7 +23,7 @@ export default function Domains({
}, },
}); });
const onSubmit = settingsOnSubmit(router, form); const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => { useEffect(() => {
if (!data) return; if (!data) return;
@@ -33,6 +37,10 @@ export default function Domains({
const { newDomain } = form.values; const { newDomain } = form.values;
if (!newDomain) return; if (!newDomain) return;
if (!DOMAIN_REGEX.test(newDomain)) {
return form.setFieldError('newDomain', 'Invalid Domain');
}
const updatedDomains = [...domains, newDomain.trim()]; const updatedDomains = [...domains, newDomain.trim()];
setDomains(updatedDomains); setDomains(updatedDomains);
form.setValues({ newDomain: '' }); form.setValues({ newDomain: '' });

View File

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

View File

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

View File

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

View File

@@ -12,8 +12,8 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react'; import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit'; import { settingsOnSubmit } from '../settingsOnSubmit';
export default function Oauth({ export default function Oauth({
@@ -21,7 +21,8 @@ export default function Oauth({
}: { }: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean }; swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) { }) {
const router = useRouter(); const navigate = useNavigate();
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
oauthBypassLocalLogin: false, oauthBypassLocalLogin: false,
@@ -85,7 +86,7 @@ export default function Oauth({
} }
} }
return settingsOnSubmit(router, form)(values); return settingsOnSubmit(navigate, form)(values);
}; };
useEffect(() => { useEffect(() => {
@@ -147,32 +148,34 @@ export default function Oauth({
{...form.getInputProps('oauthLoginOnly', { type: 'checkbox' })} {...form.getInputProps('oauthLoginOnly', { type: 'checkbox' })}
/> />
</SimpleGrid> </SimpleGrid>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<Paper withBorder p='sm'>
<Anchor href='https://discord.com/developers/applications' target='_blank'>
<Title order={4} mb='sm'>
Discord
</Title>
</Anchor>
<TextInput label='Discord Client ID' {...form.getInputProps('oauthDiscordClientId')} /> <Paper withBorder p='sm' my='sm'>
<TextInput label='Discord Client Secret' {...form.getInputProps('oauthDiscordClientSecret')} /> <Anchor href='https://discord.com/developers/applications' target='_blank'>
<TextInput <Title order={4} mb='sm'>
label='Discord Allowed IDs' Discord
description='A comma-separated list of Discord user IDs that are allowed to log in. Leave empty to disable allow list.' </Title>
{...form.getInputProps('oauthDiscordAllowedIds')} </Anchor>
/>
<TextInput <TextInput label='Discord Client ID' {...form.getInputProps('oauthDiscordClientId')} />
label='Discord Denied IDs' <TextInput label='Discord Client Secret' {...form.getInputProps('oauthDiscordClientSecret')} />
description='A comma-separated list of Discord user IDs that are denied from logging in. Leave empty to disable deny list.' <TextInput
{...form.getInputProps('oauthDiscordDeniedIds')} label='Discord Allowed IDs'
/> description='A comma-separated list of Discord user IDs that are allowed to log in. Leave empty to disable allow list.'
<TextInput {...form.getInputProps('oauthDiscordAllowedIds')}
label='Discord Redirect URL' />
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.' <TextInput
{...form.getInputProps('oauthDiscordRedirectUri')} label='Discord Denied IDs'
/> description='A comma-separated list of Discord user IDs that are denied from logging in. Leave empty to disable deny list.'
</Paper> {...form.getInputProps('oauthDiscordDeniedIds')}
/>
<TextInput
label='Discord Redirect URL'
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
{...form.getInputProps('oauthDiscordRedirectUri')}
/>
</Paper>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<Paper withBorder p='sm'> <Paper withBorder p='sm'>
<Anchor href='https://console.developers.google.com/' target='_blank'> <Anchor href='https://console.developers.google.com/' target='_blank'>
<Title order={4} mb='sm'> <Title order={4} mb='sm'>
@@ -188,16 +191,14 @@ export default function Oauth({
{...form.getInputProps('oauthGoogleRedirectUri')} {...form.getInputProps('oauthGoogleRedirectUri')}
/> />
</Paper> </Paper>
</SimpleGrid>
<Paper withBorder p='sm' my='md'> <Paper withBorder p='sm'>
<Anchor href='https://github.com/settings/developers' target='_blank'> <Anchor href='https://github.com/settings/developers' target='_blank'>
<Title order={4} mb='sm'> <Title order={4} mb='sm'>
GitHub GitHub
</Title> </Title>
</Anchor> </Anchor>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<TextInput label='GitHub Client ID' {...form.getInputProps('oauthGithubClientId')} /> <TextInput label='GitHub Client ID' {...form.getInputProps('oauthGithubClientId')} />
<TextInput label='GitHub Client Secret' {...form.getInputProps('oauthGithubClientSecret')} /> <TextInput label='GitHub Client Secret' {...form.getInputProps('oauthGithubClientSecret')} />
<TextInput <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.' 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')} {...form.getInputProps('oauthGithubRedirectUri')}
/> />
</SimpleGrid> </Paper>
</Paper> </SimpleGrid>
<Paper withBorder p='sm' my='md'> <Paper withBorder p='sm' my='md'>
<Title order={4}>OpenID Connect</Title> <Title order={4}>OpenID Connect</Title>

View File

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

View File

@@ -12,8 +12,8 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react'; import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit'; import { settingsOnSubmit } from '../settingsOnSubmit';
export default function Ratelimit({ export default function Ratelimit({
@@ -21,7 +21,8 @@ export default function Ratelimit({
}: { }: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean }; swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) { }) {
const router = useRouter(); const navigate = useNavigate();
const form = useForm<{ const form = useForm<{
ratelimitEnabled: boolean; ratelimitEnabled: boolean;
ratelimitMax: number; ratelimitMax: number;
@@ -61,7 +62,7 @@ export default function Ratelimit({
values.ratelimitWindow = null; values.ratelimitWindow = null;
} }
return settingsOnSubmit(router, form)(values); return settingsOnSubmit(navigate, form)(values);
}; };
useEffect(() => { useEffect(() => {
@@ -119,7 +120,7 @@ export default function Ratelimit({
<TextInput <TextInput
label='Allow List' label='Allow List'
description='A comma-separated list of IP addresses to bypass the ratelimit.' 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')} {...form.getInputProps('ratelimitAllowList')}
/> />
</SimpleGrid> </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 { Button, LoadingOverlay, Paper, SimpleGrid, Text, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react'; import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit'; import { settingsOnSubmit } from '../settingsOnSubmit';
export default function Tasks({ export default function Tasks({
@@ -11,7 +11,8 @@ export default function Tasks({
}: { }: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean }; swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) { }) {
const router = useRouter(); const navigate = useNavigate();
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
tasksDeleteInterval: '30m', tasksDeleteInterval: '30m',
@@ -25,7 +26,7 @@ export default function Tasks({
}), }),
}); });
const onSubmit = settingsOnSubmit(router, form); const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => { useEffect(() => {
if (!data) return; 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 { Button, LoadingOverlay, NumberInput, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react'; import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit'; import { settingsOnSubmit } from '../settingsOnSubmit';
export default function Urls({ export default function Urls({
@@ -11,7 +11,8 @@ export default function Urls({
}: { }: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean }; swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) { }) {
const router = useRouter(); const navigate = useNavigate();
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
urlsRoute: '/go', urlsRoute: '/go',
@@ -22,7 +23,7 @@ export default function Urls({
}), }),
}); });
const onSubmit = settingsOnSubmit(router, form); const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => { useEffect(() => {
if (!data) return; 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 { Button, Grid, JsonInput, Paper, Switch, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react'; import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit'; import { settingsOnSubmit } from '../settingsOnSubmit';
const defaultExternalLinks = [ const defaultExternalLinks = [
@@ -22,7 +22,8 @@ export default function Website({
}: { }: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean }; swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) { }) {
const router = useRouter(); const navigate = useNavigate();
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
websiteTitle: 'Zipline', websiteTitle: 'Zipline',
@@ -72,7 +73,7 @@ export default function Website({
sendValues.websiteLoginBackgroundBlur = values.websiteLoginBackgroundBlur; sendValues.websiteLoginBackgroundBlur = values.websiteLoginBackgroundBlur;
return settingsOnSubmit(router, form)(sendValues); return settingsOnSubmit(navigate, form)(sendValues);
}; };
useEffect(() => { useEffect(() => {

View File

@@ -3,12 +3,12 @@ import React from 'react';
import { Response } from '@/lib/api/response'; import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi'; import { fetchApi } from '@/lib/fetchApi';
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import { NextRouter } from 'next/router';
import { mutate } from 'swr'; import { mutate } from 'swr';
import { IconDeviceFloppy } from '@tabler/icons-react'; import { IconDeviceFloppy } from '@tabler/icons-react';
import { useForm } from '@mantine/form'; 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) => { return async (values: unknown) => {
const { data, error } = await fetchApi<Response['/api/server/settings']>( const { data, error } = await fetchApi<Response['/api/server/settings']>(
'/api/server/settings', '/api/server/settings',
@@ -41,7 +41,9 @@ export function settingsOnSubmit(router: NextRouter, form: ReturnType<typeof use
await fetch('/reload'); await fetch('/reload');
mutate('/api/server/settings', data); 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 { eitherTrue } from '@/lib/primitive';
import { isAdministrator } from '@/lib/role'; import { isAdministrator } from '@/lib/role';
import { useUserStore } from '@/lib/store/user'; import { useUserStore } from '@/lib/store/user';
import { Group, SimpleGrid, Title } from '@mantine/core'; import { Group, SimpleGrid, Stack, Title } from '@mantine/core';
import SettingsAvatar from './parts/SettingsAvatar'; import { lazy } from 'react';
import SettingsDashboard from './parts/SettingsDashboard';
import SettingsFileView from './parts/SettingsFileView'; const SettingsAvatar = lazy(() => import('./parts/SettingsAvatar'));
import SettingsGenerators from './parts/SettingsGenerators'; const SettingsDashboard = lazy(() => import('./parts/SettingsDashboard'));
import SettingsMfa from './parts/SettingsMfa'; const SettingsFileView = lazy(() => import('./parts/SettingsFileView'));
import SettingsOAuth from './parts/SettingsOAuth'; const SettingsGenerators = lazy(() => import('./parts/SettingsGenerators'));
import SettingsServerActions from './parts/SettingsServerUtil'; const SettingsMfa = lazy(() => import('./parts/SettingsMfa'));
import SettingsUser from './parts/SettingsUser'; const SettingsServerActions = lazy(() => import('./parts/SettingsServerUtil'));
import SettingsExports from './parts/SettingsExports'; const SettingsUser = lazy(() => import('./parts/SettingsUser'));
import SettingsSessions from './parts/SettingsSessions'; const SettingsExports = lazy(() => import('./parts/SettingsExports'));
const SettingsSessions = lazy(() => import('./parts/SettingsSessions'));
const SettingsOAuth = lazy(() => import('./parts/SettingsOAuth'));
export default function DashboardSettings() { export default function DashboardSettings() {
const config = useConfig(); const config = useConfig();
const user = useUserStore((state) => state.user); const user = useUserStore((state) => state.user);
console.log(config.oauthEnabled);
return ( return (
<> <>
<Group gap='sm'> <Group gap='sm'>
@@ -29,19 +33,24 @@ export default function DashboardSettings() {
<SettingsAvatar /> <SettingsAvatar />
<SettingsSessions /> <Stack gap='sm'>
<SettingsSessions />
{config.features.oauthRegistration && <SettingsOAuth />} <SettingsDashboard />
</Stack>
<SettingsDashboard />
<SettingsFileView /> <SettingsFileView />
{eitherTrue(
config.oauthEnabled.discord,
config.oauthEnabled.github,
config.oauthEnabled.google,
config.oauthEnabled.oidc,
) && <SettingsOAuth />}
{eitherTrue(config.mfa.totp.enabled, config.mfa.passkeys) && <SettingsMfa />} {eitherTrue(config.mfa.totp.enabled, config.mfa.passkeys) && <SettingsMfa />}
<SettingsGenerators />
<SettingsExports /> <SettingsExports />
<SettingsGenerators />
{isAdministrator(user?.role) && <SettingsServerActions />} {isAdministrator(user?.role) && <SettingsServerActions />}
</SimpleGrid> </SimpleGrid>

View File

@@ -30,7 +30,7 @@ export default function SettingsDashboard() {
}); });
return ( return (
<Paper withBorder p='sm'> <Paper withBorder p='sm' h='100%'>
<Title order={2}>Dashboard Settings</Title> <Title order={2}>Dashboard Settings</Title>
<Text size='sm' c='dimmed' mt={3}> <Text size='sm' c='dimmed' mt={3}>
These settings are saved in your browser. 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 { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes'; 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 { modals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import { IconDownload, IconPlus, IconTrashFilled } from '@tabler/icons-react'; import { IconDownload, IconPlus, IconTrashFilled } from '@tabler/icons-react';
import Link from 'next/link'; import { Link } from 'react-router-dom';
import useSWR from 'swr'; import useSWR from 'swr';
export default function SettingsExports() { export default function SettingsExports() {
@@ -64,53 +65,64 @@ export default function SettingsExports() {
New Export New Export
</Button> </Button>
<Title order={4} mt='sm'>
Exports
</Title>
{data?.length === 0 ? ( {data?.length === 0 ? (
<Paper p='sm' mt='sm' withBorder> <Paper p='sm' mt='sm' withBorder>
No exports found. Click the button above to start a new export. No exports found. Click the button above to start a new export.
</Paper> </Paper>
) : ( ) : (
<ScrollArea.Autosize mah={500} type='auto'> <ScrollArea.Autosize mah={500} type='auto'>
<Table highlightOnHover stickyHeader> <Paper withBorder p={0} mt='sm'>
<Table.Thead> <Table highlightOnHover stickyHeader>
<Table.Tr> <Table.Thead>
<Table.Th>ID</Table.Th> <Table.Tr>
<Table.Th>Started On</Table.Th> <Table.Th>ID</Table.Th>
<Table.Th>Files</Table.Th> <Table.Th>Started</Table.Th>
<Table.Th>Size</Table.Th> <Table.Th>Files</Table.Th>
</Table.Tr> <Table.Th>Size</Table.Th>
</Table.Thead>
<Table.Tbody>
{isLoading && <Table.Tr>Loading...</Table.Tr>}
{data?.map((exportDb) => (
<Table.Tr key={exportDb.id}>
<Table.Td>{exportDb.id}</Table.Td>
<Table.Td>{new Date(exportDb.createdAt).toLocaleString()}</Table.Td>
<Table.Td>{exportDb.files}</Table.Td>
<Table.Td>{exportDb.completed ? bytes(Number(exportDb.size)) : ''}</Table.Td>
<Table.Td>
<Group>
<ActionIcon onClick={() => handleDelete(exportDb.id)}>
<IconTrashFilled size='1rem' />
</ActionIcon>
<ActionIcon
component={Link}
target='_blank'
href={`/api/user/export?id=${exportDb.id}`}
disabled={!exportDb.completed}
>
<IconDownload size='1rem' />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr> </Table.Tr>
))} </Table.Thead>
</Table.Tbody> <Table.Tbody>
</Table> {isLoading && <Table.Tr>Loading...</Table.Tr>}
{data?.map((exportDb) => (
<Table.Tr key={exportDb.id}>
<Table.Td maw={140}>
<Tooltip
label={`${exportDb.id} is ${exportDb.completed ? 'completed' : 'in progress'}`}
>
<Text
style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}
c={exportDb.completed ? 'green' : 'dimmed'}
>
{exportDb.id}
</Text>
</Tooltip>
</Table.Td>
<Table.Td>
<RelativeDate date={new Date(exportDb.createdAt)} />
</Table.Td>
<Table.Td>{exportDb.files}</Table.Td>
<Table.Td>{exportDb.completed ? bytes(Number(exportDb.size)) : ''}</Table.Td>
<Table.Td w={95}>
<Group>
<ActionIcon onClick={() => handleDelete(exportDb.id)}>
<IconTrashFilled size='1rem' />
</ActionIcon>
<ActionIcon
component={Link}
target='_blank'
to={`/api/user/export?id=${exportDb.id}`}
disabled={!exportDb.completed}
>
<IconDownload size='1rem' />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
</ScrollArea.Autosize> </ScrollArea.Autosize>
)} )}
</Paper> </Paper>

View File

@@ -2,6 +2,7 @@ import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi'; import { fetchApi } from '@/lib/fetchApi';
import { useUserStore } from '@/lib/store/user'; import { useUserStore } from '@/lib/store/user';
import { import {
Anchor,
Button, Button,
ColorInput, ColorInput,
Divider, Divider,
@@ -97,7 +98,10 @@ export default function SettingsFileView() {
<Paper withBorder p='sm'> <Paper withBorder p='sm'>
<Title order={2}>Viewing Files</Title> <Title order={2}>Viewing Files</Title>
<Text c='dimmed' mt='xs'> <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> </Text>
<Stack gap='sm' mt='xs'> <Stack gap='sm' mt='xs'>
<form onSubmit={form.onSubmit(onSubmit)}> <form onSubmit={form.onSubmit(onSubmit)}>

View File

@@ -13,13 +13,13 @@ import {
Text, Text,
} from '@mantine/core'; } from '@mantine/core';
import { IconDownload, IconEyeFilled, IconGlobe, IconPercentage, IconWriting } from '@tabler/icons-react'; import { IconDownload, IconEyeFilled, IconGlobe, IconPercentage, IconWriting } from '@tabler/icons-react';
import Link from 'next/link';
import React, { useReducer, useState } from 'react'; import React, { useReducer, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { flameshot } from './generators/flameshot'; import { flameshot } from './generators/flameshot';
import { sharex } from './generators/sharex'; import { sharex } from './generators/sharex';
import { shell } from './generators/shell'; import { shell } from './generators/shell';
import { ishare } from './generators/ishare'; import { ishare } from './generators/ishare';
import { Link } from 'react-router-dom';
export type GeneratorOptions = { export type GeneratorOptions = {
deletesAt: string | null; deletesAt: string | null;
@@ -283,7 +283,7 @@ export default function GeneratorButton({
description={ description={
<> <>
If using a compositor such as{' '} 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 Hyprland
</Anchor> </Anchor>
, this option will set the <Code>XDG_CURRENT_DESKTOP=sway</Code> to workaround , 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 { Anchor, Code, Group, Paper, Text, Title, Image as MantineImage } from '@mantine/core';
import { IconPrompt } from '@tabler/icons-react'; import { IconPrompt } from '@tabler/icons-react';
import Image from 'next/image';
import GeneratorButton from './GeneratorButton'; import GeneratorButton from './GeneratorButton';
import Link from 'next/link'; import { Link } from 'react-router-dom';
export default function SettingsGenerators() { export default function SettingsGenerators() {
return ( return (
@@ -17,35 +16,30 @@ export default function SettingsGenerators() {
<GeneratorButton <GeneratorButton
name='ShareX' name='ShareX'
icon={ 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 <GeneratorButton
name='Flameshot' name='Flameshot'
icon={ icon={
<Image <img width={24} height={24} alt='flameshot logo' src='https://flameshot.org/flameshot-icon.svg' />
width={24}
height={24}
alt='flameshot logo'
src='https://flameshot.org/flameshot-icon.svg'
/>
} }
desc={ desc={
<> <>
To use this script, you need{' '} To use this script, you need{' '}
<Anchor component={Link} href='https://flameshot.org'> <Anchor component={Link} to='https://flameshot.org'>
Flameshot Flameshot
</Anchor> </Anchor>
,{' '} ,{' '}
<Anchor component={Link} href='https://curl.se/'> <Anchor component={Link} to='https://curl.se/'>
<Code>curl</Code> <Code>curl</Code>
</Anchor> </Anchor>
,{' '} ,{' '}
<Anchor component={Link} href='https://github.com/stedolan/jq'> <Anchor component={Link} to='https://github.com/stedolan/jq'>
<Code>jq</Code> <Code>jq</Code>
</Anchor> </Anchor>
, and{' '} , and{' '}
<Anchor component={Link} href='https://github.com/astrand/xclip'> <Anchor component={Link} to='https://github.com/astrand/xclip'>
<Code>xclip</Code> (linux only) <Code>xclip</Code> (linux only)
</Anchor>{' '} </Anchor>{' '}
installed. This script is intended for use on Linux and macOS only (see options below). installed. This script is intended for use on Linux and macOS only (see options below).
@@ -75,19 +69,19 @@ export default function SettingsGenerators() {
desc={ desc={
<> <>
To use this script, you need <Code>bash</Code>,{' '} 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> <Code>curl</Code>
</Anchor> </Anchor>
,{' '} ,{' '}
<Anchor component={Link} href='https://darwinsys.com/file/'> <Anchor component={Link} to='https://darwinsys.com/file/'>
<Code>file</Code> <Code>file</Code>
</Anchor> </Anchor>
,{' '} ,{' '}
<Anchor component={Link} href='https://github.com/stedolan/jq'> <Anchor component={Link} to='https://github.com/stedolan/jq'>
<Code>jq</Code> <Code>jq</Code>
</Anchor> </Anchor>
, and{' '} , and{' '}
<Anchor component={Link} href='https://github.com/astrand/xclip'> <Anchor component={Link} to='https://github.com/astrand/xclip'>
<Code>xclip</Code> (linux only) <Code>xclip</Code> (linux only)
</Anchor>{' '} </Anchor>{' '}
installed. This script is intended for use on Linux and macOS only (see options below). 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 { ActionIcon, Button, Group, Modal, Paper, Stack, Text, TextInput } from '@mantine/core';
import { modals } from '@mantine/modals'; import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { UserPasskey } from '../../../../../../generated/client'; import { UserPasskey } from '@/prisma/client';
import { IconKey, IconKeyOff, IconTrashFilled } from '@tabler/icons-react'; import { IconKey, IconKeyOff, IconTrashFilled } from '@tabler/icons-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { mutate } from 'swr'; import { mutate } from 'swr';

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import { Button, Paper, SimpleGrid, Skeleton, Text, Title } from '@mantine/core'
import { modals } from '@mantine/modals'; import { modals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import { IconLogout } from '@tabler/icons-react'; import { IconLogout } from '@tabler/icons-react';
import Link from 'next/link'; import { Link } from 'react-router-dom';
import useSWR from 'swr'; import useSWR from 'swr';
export default function SettingsSessions() { export default function SettingsSessions() {
@@ -61,7 +61,7 @@ export default function SettingsSessions() {
> >
Log out everywhere Log out everywhere
</Button> </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 Log out of this browser
</Button> </Button>
</SimpleGrid> </SimpleGrid>

View File

@@ -1,3 +1,4 @@
import { useConfig } from '@/components/ConfigProvider';
import { Response } from '@/lib/api/response'; import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi'; import { fetchApi } from '@/lib/fetchApi';
import { useUserStore } from '@/lib/store/user'; import { useUserStore } from '@/lib/store/user';
@@ -24,11 +25,14 @@ import {
IconUser, IconUser,
IconUserCancel, IconUserCancel,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useEffect, useState } from 'react'; import { lazy, useEffect, useState } from 'react';
import { mutate } from 'swr'; import { mutate } from 'swr';
import { useShallow } from 'zustand/shallow'; import { useShallow } from 'zustand/shallow';
const SettingsAvatar = lazy(() => import('./SettingsAvatar'));
export default function SettingsUser() { export default function SettingsUser() {
const config = useConfig();
const [user, setUser] = useUserStore(useShallow((state) => [state.user, state.setUser])); const [user, setUser] = useUserStore(useShallow((state) => [state.user, state.setUser]));
const [tokenShown, setTokenShown] = useState(false); const [tokenShown, setTokenShown] = useState(false);
@@ -93,7 +97,7 @@ export default function SettingsUser() {
return ( return (
<Paper withBorder p='sm'> <Paper withBorder p='sm'>
<Title order={2}>User Info</Title> <Title order={2}>User</Title>
<Text c='dimmed' size='sm' mb='sm'> <Text c='dimmed' size='sm' mb='sm'>
{user?.id} {user?.id}
</Text> </Text>

View File

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

View File

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

View File

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