mirror of
https://github.com/diced/zipline.git
synced 2025-12-05 20:40:12 -08:00
Compare commits
109 Commits
v4.1.2
...
69dfad201b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69dfad201b | ||
|
|
ee1681497e | ||
|
|
2f19140085 | ||
|
|
c9d492f9d2 | ||
|
|
a7a23f3fd9 | ||
|
|
36ffb669b2 | ||
|
|
f0ee4cdab3 | ||
|
|
ac41dab2b2 | ||
|
|
26661f7a83 | ||
|
|
01a73df7f3 | ||
|
|
6b1304f37b | ||
|
|
19fc87818c | ||
|
|
f168fa676d | ||
|
|
44cb10acf2 | ||
|
|
2c21101e9e | ||
|
|
ecb83d96e3 | ||
|
|
bfae105e5f | ||
|
|
3240e19710 | ||
|
|
40c12ca3f0 | ||
|
|
4907f4e450 | ||
|
|
e2e3edd208 | ||
|
|
b6abfe1ca7 | ||
|
|
ac61964c37 | ||
|
|
1924c22e1b | ||
|
|
c15bf27b8a | ||
|
|
da8edb9c5d | ||
|
|
c5ecd6fe64 | ||
|
|
0e0738f2fe | ||
|
|
97b8483eeb | ||
|
|
3f0306e436 | ||
|
|
87650d0fec | ||
|
|
0a59298fa0 | ||
|
|
8e778d4178 | ||
|
|
a92f072d62 | ||
|
|
003dba9ce4 | ||
|
|
fd8d4fbe5e | ||
|
|
ac37f13452 | ||
|
|
ef13ef755c | ||
|
|
fdb0312dbe | ||
|
|
95042e1383 | ||
|
|
f75020b115 | ||
|
|
24ad601e2a | ||
|
|
771811b4b7 | ||
|
|
459f99d507 | ||
|
|
6758fe1037 | ||
|
|
b48e9ba1e4 | ||
|
|
a9c7d694eb | ||
|
|
18c428532f | ||
|
|
6acbf00b9e | ||
|
|
471a060df2 | ||
|
|
9cfb01cd88 | ||
|
|
6442f5f3dc | ||
|
|
c43afc1145 | ||
|
|
8a5972c517 | ||
|
|
f6eefc01e2 | ||
|
|
ae7b4dacf1 | ||
|
|
71dbbb584a | ||
|
|
f03bd74865 | ||
|
|
f059dcca35 | ||
|
|
531ba13daf | ||
|
|
cd8b892a90 | ||
|
|
3575981984 | ||
|
|
81c880b1ca | ||
|
|
9b8e57bda0 | ||
|
|
4a8f90a901 | ||
|
|
6acdc72776 | ||
|
|
f78c873aae | ||
|
|
0f82bf8d90 | ||
|
|
82a7f1d0bf | ||
|
|
2fd1007e4b | ||
|
|
c360235fa8 | ||
|
|
a4404f1ae8 | ||
|
|
56d1492377 | ||
|
|
fa9bf185d5 | ||
|
|
eca6a0c5fd | ||
|
|
f58ed2f368 | ||
|
|
64c39dab76 | ||
|
|
ac08f4f797 | ||
|
|
91a2c05d3b | ||
|
|
3ccc108d43 | ||
|
|
aaaf0cf5aa | ||
|
|
db7cf70bca | ||
|
|
8b59e1dc53 | ||
|
|
da066db07e | ||
|
|
b566d13c8d | ||
|
|
6a76c5243f | ||
|
|
38a90787d0 | ||
|
|
4652ada85e | ||
|
|
5f96c762e0 | ||
|
|
651f32e7ba | ||
|
|
dcbd9e40f0 | ||
|
|
3486e9880e | ||
|
|
b058c15f26 | ||
|
|
96f60edaee | ||
|
|
d7f3e1503f | ||
|
|
dfc8fca3e0 | ||
|
|
28f7d3f618 | ||
|
|
5c0830c6da | ||
|
|
ef33fcbe1d | ||
|
|
4b1ca07510 | ||
|
|
438b9b5a67 | ||
|
|
ed1273efba | ||
|
|
e8518f92c7 | ||
|
|
fbf9e10e56 | ||
|
|
a1ee1178ae | ||
|
|
e5eaaca5a0 | ||
|
|
6e9dea989e | ||
|
|
5bc9b6ef0a | ||
|
|
6362d06253 |
@@ -1,8 +1,7 @@
|
||||
.github
|
||||
.next
|
||||
build
|
||||
node_modules
|
||||
uploads*
|
||||
.env
|
||||
.eslintcache
|
||||
generated
|
||||
src/prisma
|
||||
|
||||
81
.github/ISSUE_TEMPLATE/bug.yml
vendored
81
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -1,54 +1,71 @@
|
||||
name: Bug
|
||||
description: File a bug report
|
||||
title: 'Bug: [insert title]'
|
||||
name: Bug Report
|
||||
description: Report a reproducible bug in Zipline
|
||||
title: 'Bug: [short description of the issue]'
|
||||
labels: ['bug']
|
||||
body:
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Provide steps to reproduce the bug, and some context.
|
||||
value: 'A bug happened!'
|
||||
label: Bug description
|
||||
description: |
|
||||
Describe in detail what you were doing and what happened.
|
||||
Please include screenshots, logs, or error messages if possible, as they help diagnose the issue faster.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: version
|
||||
id: runtime-type
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version (or docker image) of Zipline are you using?
|
||||
label: How is Zipline being run?
|
||||
description:
|
||||
options:
|
||||
- Latest v4 release (ghcr.io/diced/zipline or ghcr.io/diced/zipline:latest)
|
||||
- Latest v4 commit (ghcr.io/diced/zipline:trunk)
|
||||
- Latest v3 release (ghcr.io/diced/zipline:v3)
|
||||
- Latest v3 commit (ghcr.io/diced/zipline:v3-trunk)
|
||||
- other (provide version in additional info)
|
||||
- On docker (docker, docker compose, etc.)
|
||||
- Built from source (running it through `pnpm start` or `node`, etc.)
|
||||
- Other (please specify in the "Zipline Version" section)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: runtime-version
|
||||
attributes:
|
||||
label: Zipline Version
|
||||
description: |
|
||||
Provide the version of Zipline you are using:
|
||||
- If version checking is enabled (it is by default): paste the response from `http://<domain>/api/version`
|
||||
- If using docker (and can't do the above): specify the tag you are using (`latest`, `trunk`, or a tag digest)
|
||||
- A simple version number (e.g. "4.2.1") may also suffice
|
||||
placeholder: "4.2.1"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: browsers
|
||||
attributes:
|
||||
label: What browser(s) are you seeing the problem on?
|
||||
label: If applicable, what browsers are you seeing this issue on?
|
||||
multiple: true
|
||||
options:
|
||||
- Firefox
|
||||
- Chromium-based (Chrome, Edge, Brave, Opera, mobile chrome/chromium based, etc)
|
||||
- Safari
|
||||
- Firefox Mobile
|
||||
- Safari Mobile
|
||||
- Chromium based (Chrome, Brave, Edge, Opera, etc.)
|
||||
- Firefox based (Firefox, Zen Browser, Waterfox, etc.)
|
||||
- Safari (On macOS and/or iOS)
|
||||
- Chromium based on Android/iOS
|
||||
- Firefox based on Android/iOS
|
||||
- Other (Please specify in the "Steps to Reproduce" section)
|
||||
|
||||
- type: textarea
|
||||
id: zipline-logs
|
||||
attributes:
|
||||
label: Zipline Logs
|
||||
description: Please copy and paste any relevant log output. Not seeing anything interesting? Try adding the `DEBUG=zipline` (v4) or `DEBUG=true` (v3) environment variable to see more logs, make sure to review the output and remove any sensitive information as it can be VERY verbose at times.
|
||||
render: shell
|
||||
label: Relevant Logs
|
||||
description: |
|
||||
Paste any relevant logs from Zipline or the browser (if applicable).
|
||||
If logs don't look useful, you can enable debug mode by setting the environment variable `DEBUG=zipline` when starting Zipline.
|
||||
Then reproduce the issue and copy the logs here.
|
||||
**Note:** Debug logs may contain sensitive information.
|
||||
|
||||
- type: textarea
|
||||
id: browser-logs
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Browser Logs
|
||||
description: Please copy and paste any relevant log output.
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: Additional Info
|
||||
description: Anything else that could be used to narrow down the issue, like your config.
|
||||
label: Steps to Reproduce
|
||||
description: |
|
||||
Please list the exact steps required to reproduce the issue.
|
||||
Include any relevant configuration options, settings, or external services that may affect Zipline’s functionality.
|
||||
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -3,9 +3,9 @@ contact_links:
|
||||
- name: Feature Request
|
||||
url: https://github.com/diced/zipline/discussions/new?category=ideas&title=Your%20brief%20description%20here&labels=feature
|
||||
about: Ask for a new feature
|
||||
- name: Documentation
|
||||
url: https://zipline.diced.sh
|
||||
about: Maybe take a look a the docs?
|
||||
- name: Zipline Discord
|
||||
url: https://discord.gg/EAhCRfGxCF
|
||||
about: Ask for help with anything related to Zipline!
|
||||
- name: Zipline Docs
|
||||
url: https://zipline.diced.sh
|
||||
about: Maybe take a look a the docs?
|
||||
|
||||
8
.github/workflows/build.yml
vendored
8
.github/workflows/build.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
node: [20.x, 22.x, 24.x]
|
||||
node: [22.x, 24.x]
|
||||
arch: [amd64, arm64]
|
||||
runs-on: ubuntu-24.04${{ matrix.arch == 'arm64' && '-arm' || '' }}
|
||||
|
||||
@@ -37,10 +37,9 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
${{ steps.pnpm-cache.outputs.store_path }}
|
||||
${{ github.workspace }}/.next/cache
|
||||
key: ${{ runner.os }}-pnpm-next-store-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
|
||||
key: ${{ runner.os }}-${{ matrix.arch }}-${{ matrix.node }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-next-store-${{ hashFiles('**/package-lock.json') }}-
|
||||
${{ runner.os }}-${{ matrix.arch }}-${{ matrix.node }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||
|
||||
- name: Install
|
||||
run: pnpm install
|
||||
@@ -48,5 +47,4 @@ jobs:
|
||||
- name: Build
|
||||
env:
|
||||
ZIPLINE_BUILD: 'true'
|
||||
NEXT_TELEMETRY_DISABLED: '1'
|
||||
run: pnpm build
|
||||
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -13,16 +13,14 @@
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
build/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
@@ -37,13 +35,17 @@ yarn-error.log*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# eslint
|
||||
.eslintcache
|
||||
|
||||
# nix dev env
|
||||
!.envrc
|
||||
.direnv
|
||||
.devenv
|
||||
|
||||
# zipline
|
||||
uploads*/
|
||||
*.crt
|
||||
*.key
|
||||
generated
|
||||
src/prisma
|
||||
1
.prettierignore
Executable file
1
.prettierignore
Executable file
@@ -0,0 +1 @@
|
||||
pnpm-lock.yaml
|
||||
17
Dockerfile
17
Dockerfile
@@ -20,14 +20,20 @@ FROM base AS builder
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
|
||||
COPY src ./src
|
||||
COPY next.config.js ./next.config.js
|
||||
COPY .gitignore ./.gitignore
|
||||
|
||||
COPY postcss.config.cjs ./postcss.config.cjs
|
||||
COPY prettier.config.cjs ./prettier.config.cjs
|
||||
COPY eslint.config.mjs ./eslint.config.mjs
|
||||
COPY vite.config.ts ./vite.config.ts
|
||||
COPY tsup.config.ts ./tsup.config.ts
|
||||
COPY tsconfig.json ./tsconfig.json
|
||||
COPY mimes.json ./mimes.json
|
||||
COPY code.json ./code.json
|
||||
COPY vite-env.d.ts ./vite-env.d.ts
|
||||
COPY scripts ./scripts
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||
NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN ZIPLINE_BUILD=true pnpm run build
|
||||
|
||||
@@ -36,14 +42,11 @@ FROM base
|
||||
COPY --from=deps /zipline/node_modules ./node_modules
|
||||
|
||||
COPY --from=builder /zipline/build ./build
|
||||
COPY --from=builder /zipline/.next ./.next
|
||||
|
||||
COPY --from=builder /zipline/mimes.json ./mimes.json
|
||||
COPY --from=builder /zipline/code.json ./code.json
|
||||
COPY --from=builder /zipline/generated ./generated
|
||||
|
||||
|
||||
RUN pnpm build:prisma
|
||||
RUN pnpm prisma generate
|
||||
|
||||
# clean
|
||||
RUN rm -rf /tmp/* /root/*
|
||||
|
||||
41
README.md
41
README.md
@@ -3,16 +3,14 @@
|
||||
|
||||
The next generation ShareX / File upload server
|
||||
|
||||

|
||||

|
||||

|
||||
[](https://discord.gg/EAhCRfGxCF)
|
||||

|
||||

|
||||

|
||||
[](https://discord.gg/EAhCRfGxCF)
|
||||
|
||||

|
||||

|
||||
|
||||
[zipline.diced.sh](https://zipline.diced.sh) | [old v3.zipline.diced.sh](https://v3.zipline.diced.sh)
|
||||
|
||||
<!-- TODO: change these links and image branch -->
|
||||
Documentation: [zipline.diced.sh](https://zipline.diced.sh)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -198,6 +196,33 @@ Create a pull request on GitHub. If your PR does not pass the action checks, the
|
||||
|
||||
Here's how to setup Zipline for development
|
||||
|
||||
#### Nix
|
||||
|
||||
If you have [Nix](https://nixos.org) and [direnv](https://direnv.net/) installed, you can simply cd into the cloned directory and run the following command:
|
||||
|
||||
```bash
|
||||
direnv allow
|
||||
```
|
||||
|
||||
After doing so, your shell will be setup for development.
|
||||
|
||||
If you aren't using direnv, you can run the following command to enter the nix shell:
|
||||
|
||||
```bash
|
||||
nix develop --no-pure-eval
|
||||
```
|
||||
|
||||
Useful commands regarding the postgres server:
|
||||
|
||||
| Command | Description |
|
||||
| --------------- | --------------------------------------------- |
|
||||
| `pgup` | Starts the postgres server in the background. |
|
||||
| `pg_ctl status` | See if the postgres server is running |
|
||||
| `minioup` | Start a Minio server for testing S3 |
|
||||
| `downall` | Stops any running postgres or minio service. |
|
||||
|
||||
After familiarizing yourself with the environment, you can continue below (skipping the prerequisites since they are already installed).
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- nodejs (lts -> 20.x, 22.x)
|
||||
|
||||
10
SECURITY.md
10
SECURITY.md
@@ -2,11 +2,11 @@
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------------------------- |
|
||||
| 4.x.x | :white_check_mark: |
|
||||
| < 3 | :white_check_mark: (EOL at June 2025) |
|
||||
| < 2 | :x: |
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 4.2.x | :white_check_mark: |
|
||||
| < 3 | :x: |
|
||||
| < 2 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
||||
@@ -1,44 +1,68 @@
|
||||
// TODO: migrate everything to use eslint 9 features instead of compatibility layers
|
||||
|
||||
import unusedImports from 'eslint-plugin-unused-imports';
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import prettier from 'eslint-plugin-prettier';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
|
||||
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import js from '@eslint/js';
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all,
|
||||
});
|
||||
|
||||
const gitignorePath = path.resolve(__dirname, '.gitignore');
|
||||
const gitignoreContent = fs.readFileSync(gitignorePath, 'utf8');
|
||||
const gitignorePatterns = gitignoreContent
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() && !line.startsWith('#'))
|
||||
.map((pattern) => pattern.trim());
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: gitignorePatterns },
|
||||
|
||||
export default [
|
||||
includeIgnoreFile(gitignorePath),
|
||||
...compat.extends(
|
||||
'next/core-web-vitals',
|
||||
'plugin:prettier/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
),
|
||||
{
|
||||
extends: [
|
||||
tseslint.configs.recommended,
|
||||
reactHooksPlugin.configs['recommended-latest'],
|
||||
reactRefreshPlugin.configs.vite,
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
files: ['**/*.{js,mjs,cjs,ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'unused-imports': unusedImports,
|
||||
'@typescript-eslint': typescriptEslint,
|
||||
prettier: prettier,
|
||||
react: reactPlugin,
|
||||
'jsx-a11y': jsxA11yPlugin,
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
},
|
||||
|
||||
rules: {
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
|
||||
...prettierConfig.rules,
|
||||
'prettier/prettier': [
|
||||
'error',
|
||||
{},
|
||||
{
|
||||
fileInfoOptions: {
|
||||
withNodeModules: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
quotes: [
|
||||
'error',
|
||||
'single',
|
||||
@@ -46,13 +70,13 @@ export default [
|
||||
avoidEscape: true,
|
||||
},
|
||||
],
|
||||
|
||||
semi: ['error', 'always'],
|
||||
'jsx-quotes': ['error', 'prefer-single'],
|
||||
indent: 'off',
|
||||
'react/prop-types': 'off',
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'react-refresh/only-export-components': 'off',
|
||||
'react/jsx-uses-react': 'warn',
|
||||
'react/jsx-uses-vars': 'warn',
|
||||
'react/no-danger-with-children': 'warn',
|
||||
@@ -77,10 +101,14 @@ export default [
|
||||
argsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
);
|
||||
|
||||
254
flake.lock
generated
Normal file
254
flake.lock
generated
Normal file
@@ -0,0 +1,254 @@
|
||||
{
|
||||
"nodes": {
|
||||
"cachix": {
|
||||
"inputs": {
|
||||
"devenv": [
|
||||
"devenv"
|
||||
],
|
||||
"flake-compat": [
|
||||
"devenv"
|
||||
],
|
||||
"git-hooks": [
|
||||
"devenv",
|
||||
"git-hooks"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1748883665,
|
||||
"narHash": "sha256-R0W7uAg+BLoHjMRMQ8+oiSbTq8nkGz5RDpQ+ZfxxP3A=",
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"rev": "f707778d902af4d62d8dd92c269f8e70de09acbe",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "latest",
|
||||
"repo": "cachix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devenv": {
|
||||
"inputs": {
|
||||
"cachix": "cachix",
|
||||
"flake-compat": "flake-compat",
|
||||
"git-hooks": "git-hooks",
|
||||
"nix": "nix",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1753888869,
|
||||
"narHash": "sha256-VRYrrUmvXnBzfzuJVoI3os1H/0l8cJQ2KnrrxWkTB3E=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "bdf26a4453eff6bae835f33d519a36f77e0ca257",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devenv-root": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"narHash": "sha256-d6xi4mKdjkX2JFicDIv5niSzpyI0m/Hnm8GGAIU04kY=",
|
||||
"type": "file",
|
||||
"url": "file:///dev/null"
|
||||
},
|
||||
"original": {
|
||||
"type": "file",
|
||||
"url": "file:///dev/null"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1747046372,
|
||||
"narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"devenv",
|
||||
"nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1733312601,
|
||||
"narHash": "sha256-4pDvzqnegAfRkPwO3wmwBhVi/Sye1mzps0zHWYnP88c=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "205b12d8b7cd4802fbcb8e8ef6a0f1408781a4f9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts_2": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1753121425,
|
||||
"narHash": "sha256-TVcTNvOeWWk1DXljFxVRp+E0tzG1LhrVjOGGoMHuXio=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "644e0fc48951a860279da645ba77fe4a6e814c5e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1750779888,
|
||||
"narHash": "sha256-wibppH3g/E2lxU43ZQHC5yA/7kIKLGxVEnsnVK1BtRg=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"flake-parts": "flake-parts",
|
||||
"git-hooks-nix": [
|
||||
"devenv",
|
||||
"git-hooks"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"nixpkgs-23-11": [
|
||||
"devenv"
|
||||
],
|
||||
"nixpkgs-regression": [
|
||||
"devenv"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1752773918,
|
||||
"narHash": "sha256-dOi/M6yNeuJlj88exI+7k154z+hAhFcuB8tZktiW7rg=",
|
||||
"owner": "cachix",
|
||||
"repo": "nix",
|
||||
"rev": "031c3cf42d2e9391eee373507d8c12e0f9606779",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "devenv-2.30",
|
||||
"repo": "nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1752827260,
|
||||
"narHash": "sha256-noFjJbm/uWRcd2Lotr7ovedfhKVZT+LeJs9rU416lKQ=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1751159883,
|
||||
"narHash": "sha256-urW/Ylk9FIfvXfliA1ywh75yszAbiTEVgpPeinFyVZo=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "14a40a1d7fb9afa4739275ac642ed7301a9ba1ab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"devenv-root": "devenv-root",
|
||||
"flake-parts": "flake-parts_2",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
128
flake.nix
Normal file
128
flake.nix
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
inputs = {
|
||||
# required for some reason when entering the shell for devenv
|
||||
devenv-root = {
|
||||
url = "file+file:///dev/null";
|
||||
flake = false;
|
||||
};
|
||||
|
||||
# node 24.4.1, postgres 17
|
||||
nixpkgs.url = "github:nixos/nixpkgs/b527e89270879aaaf584c41f26b2796be634bc9d";
|
||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||
|
||||
devenv.url = "github:cachix/devenv";
|
||||
devenv.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
nixConfig = {
|
||||
extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw=";
|
||||
extra-substituters = "https://devenv.cachix.org";
|
||||
};
|
||||
|
||||
outputs =
|
||||
inputs@{ flake-parts, devenv-root, ... }:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
imports = [
|
||||
inputs.devenv.flakeModule
|
||||
];
|
||||
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"x86_64-darwin"
|
||||
"aarch64-linux"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
|
||||
perSystem =
|
||||
{
|
||||
config,
|
||||
self',
|
||||
inputs',
|
||||
pkgs,
|
||||
system,
|
||||
...
|
||||
}:
|
||||
let
|
||||
psqlConfig = {
|
||||
username = "postgres";
|
||||
password = "postgres";
|
||||
database = "zipline";
|
||||
};
|
||||
in
|
||||
{
|
||||
devenv.shells.default = {
|
||||
packages = with pkgs; [
|
||||
git
|
||||
|
||||
# to generate thumbnails
|
||||
ffmpeg
|
||||
|
||||
# for testing docker
|
||||
colima
|
||||
docker
|
||||
docker-compose
|
||||
];
|
||||
|
||||
scripts = {
|
||||
pgup.exec = ''
|
||||
process-compose up postgres -D
|
||||
'';
|
||||
|
||||
minioup.exec = ''
|
||||
process-compose up minio -D
|
||||
'';
|
||||
|
||||
downall.exec = ''
|
||||
process-compose down
|
||||
'';
|
||||
|
||||
# ensure that volumes are mounted with write access for docker containers
|
||||
start_colima.exec = ''
|
||||
colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w
|
||||
'';
|
||||
};
|
||||
|
||||
enterShell = ''
|
||||
export name="zipline-env";
|
||||
echo -e "\n[$name]: run 'pgup' to start services, 'pgdown' to stop services";
|
||||
'';
|
||||
|
||||
languages.javascript = {
|
||||
enable = true;
|
||||
package = pkgs.nodejs_24;
|
||||
|
||||
corepack.enable = true;
|
||||
};
|
||||
|
||||
services = {
|
||||
postgres = {
|
||||
enable = true;
|
||||
package = pkgs.postgresql_17;
|
||||
|
||||
initialScript = ''
|
||||
CREATE ROLE "${psqlConfig.username}" WITH LOGIN PASSWORD '${psqlConfig.password}' SUPERUSER;
|
||||
'';
|
||||
|
||||
initialDatabases = [
|
||||
{
|
||||
name = psqlConfig.database;
|
||||
user = psqlConfig.username;
|
||||
}
|
||||
];
|
||||
|
||||
listen_addresses = "0.0.0.0";
|
||||
port = 5432;
|
||||
};
|
||||
|
||||
minio = {
|
||||
enable = true;
|
||||
};
|
||||
};
|
||||
|
||||
process.managers.process-compose = {
|
||||
tui.enable = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -122,6 +122,7 @@
|
||||
["calx", ["application/vnd.ms-office.calx"]],
|
||||
["cap", ["application/vnd.tcpdump.pcap"]],
|
||||
["car", ["application/vnd.curl.car"]],
|
||||
["cast", ["application/x-asciicast"]],
|
||||
["cat", ["application/vnd.ms-pki.seccat"]],
|
||||
["cb7", ["application/x-cbr"]],
|
||||
["cba", ["application/x-cbr"]],
|
||||
|
||||
@@ -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;
|
||||
133
package.json
133
package.json
@@ -2,21 +2,16 @@
|
||||
"name": "zipline",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "4.1.2",
|
||||
"version": "4.3.1",
|
||||
"scripts": {
|
||||
"build": "cross-env pnpm run --stream \"/^build:.*/\"",
|
||||
"build:prisma": "prisma generate --no-hints",
|
||||
"build:next": "ZIPLINE_BUILD=true next build",
|
||||
"build:server": "tsup",
|
||||
"dev": "cross-env TURBOPACK=1 NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
|
||||
"dev:nd": "cross-env TURBOPACK=1 NODE_ENV=development tsx --require dotenv/config --enable-source-maps ./src/server",
|
||||
"dev:inspector": "cross-env TURBOPACK=1 NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
|
||||
"start": "cross-env NODE_ENV=production node --trace-warnings --require dotenv/config --enable-source-maps ./build/server",
|
||||
"build": "tsx scripts/build.ts",
|
||||
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
|
||||
"dev:nd": "cross-env NODE_ENV=development tsx --require dotenv/config --enable-source-maps ./src/server",
|
||||
"dev:inspector": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
|
||||
"start": "cross-env NODE_ENV=production node --trace-warnings --require dotenv/config ./build/server",
|
||||
"start:inspector": "cross-env NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
|
||||
"ctl": "NODE_ENV=production node --require dotenv/config --enable-source-maps ./build/ctl",
|
||||
"validate": "pnpm run --stream \"/^validate:.*/\"",
|
||||
"validate:lint": "eslint --cache --fix .",
|
||||
"validate:format": "prettier --write --ignore-path .gitignore .",
|
||||
"validate": "tsx scripts/validate.ts",
|
||||
"db:prototype": "prisma db push --skip-generate && prisma generate --no-hints",
|
||||
"db:migrate": "prisma migrate dev --create-only",
|
||||
"docker:engine": "colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w",
|
||||
@@ -28,94 +23,100 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.726.1",
|
||||
"@aws-sdk/lib-storage": "3.726.1",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@fastify/multipart": "^9.0.3",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/sensible": "^6.0.3",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@github/webauthn-json": "^2.1.1",
|
||||
"@mantine/charts": "^8.0.2",
|
||||
"@mantine/code-highlight": "^8.0.2",
|
||||
"@mantine/core": "^8.0.2",
|
||||
"@mantine/dates": "^8.0.2",
|
||||
"@mantine/dropzone": "^8.0.2",
|
||||
"@mantine/form": "^8.0.2",
|
||||
"@mantine/hooks": "^8.0.2",
|
||||
"@mantine/modals": "^8.0.2",
|
||||
"@mantine/notifications": "^8.0.2",
|
||||
"@prisma/client": "^6.9.0",
|
||||
"@prisma/internals": "^6.9.0",
|
||||
"@prisma/migrate": "^6.9.0",
|
||||
"@smithy/node-http-handler": "^4.0.6",
|
||||
"@tabler/icons-react": "^3.34.0",
|
||||
"argon2": "^0.43.0",
|
||||
"@mantine/charts": "^8.2.8",
|
||||
"@mantine/code-highlight": "^8.2.8",
|
||||
"@mantine/core": "^8.2.8",
|
||||
"@mantine/dates": "^8.2.8",
|
||||
"@mantine/dropzone": "^8.2.8",
|
||||
"@mantine/form": "^8.2.8",
|
||||
"@mantine/hooks": "^8.2.8",
|
||||
"@mantine/modals": "^8.2.8",
|
||||
"@mantine/notifications": "^8.2.8",
|
||||
"@prisma/adapter-pg": "6.13.0",
|
||||
"@prisma/client": "6.13.0",
|
||||
"@prisma/engines": "6.13.0",
|
||||
"@prisma/internals": "6.13.0",
|
||||
"@prisma/migrate": "6.13.0",
|
||||
"@smithy/node-http-handler": "^4.1.1",
|
||||
"@tabler/icons-react": "^3.34.1",
|
||||
"argon2": "^0.44.0",
|
||||
"asciinema-player": "^3.10.0",
|
||||
"bytes": "^3.1.2",
|
||||
"clsx": "^2.1.1",
|
||||
"colorette": "^2.0.20",
|
||||
"commander": "^14.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.5.0",
|
||||
"exif-be-gone": "^1.5.1",
|
||||
"cookie": "^1.0.2",
|
||||
"cross-env": "^10.0.0",
|
||||
"dayjs": "^1.11.18",
|
||||
"dotenv": "^17.2.2",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fastify": "^5.3.3",
|
||||
"fastify": "^5.5.0",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"fflate": "^0.8.2",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"iron-session": "^8.0.4",
|
||||
"isomorphic-dompurify": "^2.25.0",
|
||||
"isomorphic-dompurify": "^2.26.0",
|
||||
"katex": "^0.16.22",
|
||||
"mantine-datatable": "^7.17.1",
|
||||
"mantine-datatable": "^8.2.0",
|
||||
"ms": "^2.1.3",
|
||||
"multer": "2.0.1",
|
||||
"next": "^15.3.3",
|
||||
"nuqs": "^2.4.3",
|
||||
"multer": "2.0.2",
|
||||
"otplib": "^12.0.1",
|
||||
"prisma": "^6.9.0",
|
||||
"prisma": "6.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.8.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.34.2",
|
||||
"swr": "^2.3.3",
|
||||
"zod": "^3.25.51",
|
||||
"zustand": "^5.0.5"
|
||||
"sharp": "^0.34.3",
|
||||
"swr": "^2.3.6",
|
||||
"typescript-eslint": "^8.42.0",
|
||||
"vite": "^7.1.4",
|
||||
"zod": "^4.1.5",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.9",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.28.0",
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/fluent-ffmpeg": "^2.1.27",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/ms": "^2.1.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.15.30",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.33.1",
|
||||
"@typescript-eslint/parser": "^8.33.1",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-next": "^15.3.3",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-prettier": "^5.4.1",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"postcss": "^8.5.4",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"eslint-plugin-unused-imports": "^4.2.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.5.3",
|
||||
"sass": "^1.89.1",
|
||||
"prettier": "^3.6.2",
|
||||
"sass": "^1.92.0",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3"
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
"packageManager": "pnpm@10.12.1"
|
||||
}
|
||||
|
||||
5558
pnpm-lock.yaml
generated
5558
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Zipline" ADD COLUMN "oauthDiscordWhitelistIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `oauthDiscordWhitelistIds` on the `Zipline` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Zipline" DROP COLUMN "oauthDiscordWhitelistIds",
|
||||
ADD COLUMN "oauthDiscordAllowedIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
ADD COLUMN "oauthDiscordDeniedIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Zipline" ADD COLUMN "domains" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "filesDefaultCompressionFormat" TEXT DEFAULT 'jpg';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "featuresThumbnailsFormat" TEXT NOT NULL DEFAULT 'jpg';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "coreTrustProxy" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -1,6 +1,8 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../generated/client"
|
||||
provider = "prisma-client"
|
||||
output = "../src/prisma"
|
||||
moduleFormat = "cjs"
|
||||
previewFeatures = ["queryCompiler", "driverAdapters"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@@ -18,6 +20,7 @@ model Zipline {
|
||||
coreReturnHttpsUrls Boolean @default(false)
|
||||
coreDefaultDomain String?
|
||||
coreTempDirectory String // default join(tmpdir(), 'zipline')
|
||||
coreTrustProxy Boolean @default(false)
|
||||
|
||||
chunksEnabled Boolean @default(true)
|
||||
chunksMax String @default("95mb")
|
||||
@@ -40,6 +43,7 @@ model Zipline {
|
||||
filesRemoveGpsMetadata Boolean @default(false)
|
||||
filesRandomWordsNumAdjectives Int @default(2)
|
||||
filesRandomWordsSeparator String @default("-")
|
||||
filesDefaultCompressionFormat String? @default("jpg")
|
||||
|
||||
urlsRoute String @default("/go")
|
||||
urlsLength Int @default(6)
|
||||
@@ -53,6 +57,7 @@ model Zipline {
|
||||
|
||||
featuresThumbnailsEnabled Boolean @default(true)
|
||||
featuresThumbnailsNumberThreads Int @default(4)
|
||||
featuresThumbnailsFormat String @default("jpg")
|
||||
|
||||
featuresMetricsEnabled Boolean @default(true)
|
||||
featuresMetricsAdminOnly Boolean @default(false)
|
||||
@@ -82,6 +87,8 @@ model Zipline {
|
||||
oauthDiscordClientId String?
|
||||
oauthDiscordClientSecret String?
|
||||
oauthDiscordRedirectUri String?
|
||||
oauthDiscordAllowedIds String[] @default([])
|
||||
oauthDiscordDeniedIds String[] @default([])
|
||||
|
||||
oauthGoogleClientId String?
|
||||
oauthGoogleClientSecret String?
|
||||
@@ -133,6 +140,8 @@ model Zipline {
|
||||
pwaDescription String @default("Zipline")
|
||||
pwaThemeColor String @default("#000000")
|
||||
pwaBackgroundColor String @default("#000000")
|
||||
|
||||
domains String[] @default([])
|
||||
}
|
||||
|
||||
model User {
|
||||
|
||||
24
scripts/build.ts
Normal file
24
scripts/build.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { run, step } from '.';
|
||||
import { lintStep } from './lint';
|
||||
|
||||
run(
|
||||
'build',
|
||||
|
||||
lintStep,
|
||||
step('prisma', 'prisma generate'),
|
||||
step('typecheck', 'tsc', () => !process.argv.includes('--skip')),
|
||||
|
||||
// builds
|
||||
step('server', 'tsup'),
|
||||
|
||||
// client stuff
|
||||
step('client', 'vite build'),
|
||||
step(
|
||||
'client/ssr/view',
|
||||
'vite build --ssr ssr-view/server.tsx -m ssr-view --outDir ../../build/ssr --emptyOutDir=false',
|
||||
),
|
||||
step(
|
||||
'client/ssr/view-url',
|
||||
'vite build --ssr ssr-view-url/server.tsx -m ssr-view-url --outDir ../../build/ssr --emptyOutDir=false',
|
||||
),
|
||||
);
|
||||
49
scripts/index.ts
Normal file
49
scripts/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export function step(name: string, command: string, condition: () => boolean = () => true) {
|
||||
return {
|
||||
name,
|
||||
command,
|
||||
condition,
|
||||
};
|
||||
}
|
||||
|
||||
export type Step = ReturnType<typeof step>;
|
||||
|
||||
function log(message: string) {
|
||||
console.log(`\n${message}\n`);
|
||||
}
|
||||
|
||||
export async function run(name: string, ...steps: Step[]) {
|
||||
const { execSync } = await import('child_process');
|
||||
|
||||
const runOne = process.argv[2];
|
||||
if (runOne) {
|
||||
const match = steps.find((s) => `${name}/${s.name}` === runOne);
|
||||
if (!match) {
|
||||
console.error(`x No step found with name "${runOne}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
steps = [match];
|
||||
}
|
||||
|
||||
const start = process.hrtime();
|
||||
for (const step of steps) {
|
||||
if (!step.condition()) {
|
||||
log(`- Skipping step "${name}/${step.name}"...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
log(`> Running step "${name}/${step.name}"...`);
|
||||
execSync(step.command, { stdio: 'inherit' });
|
||||
} catch {
|
||||
console.error(`x Step "${name}/${step.name}" failed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const diff = process.hrtime(start);
|
||||
const time = diff[0] * 1e9 + diff[1];
|
||||
const timeStr = time > 1e9 ? `${(time / 1e9).toFixed(2)}s` : `${(time / 1e6).toFixed(2)}ms`;
|
||||
log(`✓ Steps in "${name}" completed in ${timeStr}.`);
|
||||
}
|
||||
3
scripts/lint.ts
Normal file
3
scripts/lint.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { step } from '.';
|
||||
|
||||
export const lintStep = step('lint', 'eslint .');
|
||||
9
scripts/validate.ts
Normal file
9
scripts/validate.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { run, step } from '.';
|
||||
import { lintStep } from './lint';
|
||||
|
||||
run(
|
||||
'validate',
|
||||
|
||||
lintStep,
|
||||
step('format', 'prettier --write --ignore-path .gitignore .'),
|
||||
);
|
||||
47
src/client/Root.tsx
Normal file
47
src/client/Root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/client/error/DashboardErrorBoundary.tsx
Normal file
11
src/client/error/DashboardErrorBoundary.tsx
Normal 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, type: 'dashboard' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
38
src/client/error/GenericError.tsx
Normal file
38
src/client/error/GenericError.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Container, Paper, ScrollArea, Stack, Text, Title } from '@mantine/core';
|
||||
import { useRouteError } from 'react-router-dom';
|
||||
import FourOhFour from '../pages/404';
|
||||
|
||||
export default function GenericError({
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
}: {
|
||||
title?: string;
|
||||
message?: string;
|
||||
details?: Record<string, any>;
|
||||
}) {
|
||||
const routerError: any = useRouteError();
|
||||
if (routerError?.status === 404) return <FourOhFour />;
|
||||
|
||||
const routeError = JSON.parse(JSON.stringify(routerError, Object.getOwnPropertyNames(routerError)));
|
||||
|
||||
console.error(routerError);
|
||||
|
||||
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}>
|
||||
<ScrollArea>
|
||||
<pre style={{ margin: 0 }}>{JSON.stringify({ routeError, details }, null, 2)}</pre>
|
||||
</ScrollArea>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
11
src/client/error/RootErrorBoundary.tsx
Normal file
11
src/client/error/RootErrorBoundary.tsx
Normal 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, type: 'root' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
12
src/client/index.html
Normal file
12
src/client/index.html
Normal 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
18
src/client/main.tsx
Normal 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>,
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button, Center, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconArrowLeft } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function FourOhFour() {
|
||||
return (
|
||||
@@ -11,12 +11,16 @@ export default function FourOhFour() {
|
||||
Page not found
|
||||
</Text>
|
||||
|
||||
<Button component={Link} href='/' color='blue' fullWidth leftSection={<IconArrowLeft size='1rem' />}>
|
||||
<Button
|
||||
component={Link}
|
||||
to='/auth/login'
|
||||
color='blue'
|
||||
fullWidth
|
||||
leftSection={<IconArrowLeft size='1rem' />}
|
||||
>
|
||||
Go home
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
FourOhFour.title = '404';
|
||||
184
src/pages/auth/login.tsx → src/client/pages/auth/login.tsx
Executable file → Normal file
184
src/pages/auth/login.tsx → src/client/pages/auth/login.tsx
Executable file → Normal file
@@ -1,11 +1,8 @@
|
||||
import ExternalAuthButton from '@/components/pages/login/ExternalAuthButton';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { SafeConfig } from '@/lib/config/safe';
|
||||
import { getZipline } from '@/lib/db/models/zipline';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { withSafeConfig } from '@/lib/middleware/next/withSafeConfig';
|
||||
import useLogin from '@/lib/hooks/useLogin';
|
||||
import { authenticateWeb } from '@/lib/passkey';
|
||||
import { eitherTrue } from '@/lib/primitive';
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
@@ -34,28 +31,43 @@ import {
|
||||
IconUserPlus,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { InferGetServerSidePropsType } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import GenericError from '../../error/GenericError';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
|
||||
export default function Login({ config }: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const router = useRouter();
|
||||
const { data, isLoading, mutate } = useSWR<Response['/api/user']>('/api/user', {
|
||||
refreshInterval: 120000,
|
||||
export default function Login() {
|
||||
useTitle('Login');
|
||||
|
||||
const location = useLocation();
|
||||
const query = new URLSearchParams(location.search);
|
||||
const { user, mutate } = useLogin();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
data: config,
|
||||
error: configError,
|
||||
isLoading: configLoading,
|
||||
} = useSWR<Response['/api/server/public']>('/api/server/public', {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
refreshWhenHidden: false,
|
||||
revalidateIfStale: false,
|
||||
});
|
||||
|
||||
const showLocalLogin =
|
||||
router.query.local === 'true' ||
|
||||
query.get('local') === 'true' ||
|
||||
!(
|
||||
config.oauth.bypassLocalLogin && Object.values(config.oauthEnabled).filter((x) => x === true).length > 0
|
||||
config?.oauth?.bypassLocalLogin &&
|
||||
Object.values(config?.oauthEnabled ?? {}).filter((x) => x === true).length > 0
|
||||
);
|
||||
|
||||
const willRedirect =
|
||||
config.oauth.bypassLocalLogin &&
|
||||
Object.values(config.oauthEnabled).filter((x) => x === true).length === 1 &&
|
||||
router.query.local !== 'true';
|
||||
config?.oauth?.bypassLocalLogin &&
|
||||
Object.values(config?.oauthEnabled ?? {}).filter((x) => x === true).length === 1 &&
|
||||
query.get('local') !== 'true';
|
||||
|
||||
const [totpOpen, setTotpOpen] = useState(false);
|
||||
const [pinDisabled, setPinDisabled] = useState(false);
|
||||
@@ -65,12 +77,6 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
|
||||
const [passkeyErrored, setPasskeyErrored] = useState(false);
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.user) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: '',
|
||||
@@ -95,9 +101,10 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
|
||||
});
|
||||
|
||||
if (error) {
|
||||
if (error.error === 'Invalid username') form.setFieldError('username', 'Invalid username');
|
||||
else if (error.error === 'Invalid password') form.setFieldError('password', 'Invalid password');
|
||||
else if (error.error === 'Invalid code') setPinError(error.error!);
|
||||
if (error.error === 'Invalid username or password') {
|
||||
form.setFieldError('username', 'Invalid username');
|
||||
form.setFieldError('password', 'Invalid password');
|
||||
} else if (error.error === 'Invalid code') setPinError(error.error!);
|
||||
setPinDisabled(false);
|
||||
} else {
|
||||
if (data!.totp) {
|
||||
@@ -122,7 +129,6 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
|
||||
try {
|
||||
setPasskeyLoading(true);
|
||||
const res = await authenticateWeb();
|
||||
|
||||
const { data, error } = await fetchApi<Response['/api/auth/webauthn']>('/api/auth/webauthn', 'POST', {
|
||||
auth: res.toJSON(),
|
||||
});
|
||||
@@ -145,16 +151,23 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (willRedirect) {
|
||||
if (user) {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log({ willRedirect, config });
|
||||
if (willRedirect && config) {
|
||||
const provider = Object.keys(config.oauthEnabled).find(
|
||||
(x) => config.oauthEnabled[x as keyof SafeConfig['oauthEnabled']] === true,
|
||||
(x) => config.oauthEnabled[x as keyof typeof config.oauthEnabled] === true,
|
||||
);
|
||||
|
||||
if (provider) {
|
||||
router.push(`/api/auth/oauth/${provider}`);
|
||||
window.location.href = `/api/auth/oauth/${provider.toLowerCase()}`;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [willRedirect, config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (passkeyErrored) {
|
||||
@@ -171,6 +184,23 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
|
||||
}
|
||||
}, [passkeyErrored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.firstSetup) navigate('/auth/setup');
|
||||
}, [config]);
|
||||
|
||||
if (configLoading) return <LoadingOverlay visible />;
|
||||
|
||||
if (configError)
|
||||
return (
|
||||
<GenericError
|
||||
title='Error loading configuration'
|
||||
message='Could not load server configuration...'
|
||||
details={configError}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!config) return <LoadingOverlay visible />;
|
||||
|
||||
return (
|
||||
<>
|
||||
{willRedirect && !showLocalLogin && <LoadingOverlay visible />}
|
||||
@@ -254,7 +284,10 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
|
||||
ta='center'
|
||||
style={{
|
||||
whiteSpace: 'normal',
|
||||
fontSize: `clamp(20px, ${Math.max(50 - (config.website.title?.length ?? 0) / 2, 20)}px, 50px)`,
|
||||
fontSize: `clamp(20px, ${Math.max(
|
||||
50 - (config.website.title?.length ?? 0) / 2,
|
||||
20,
|
||||
)}px, 50px)`,
|
||||
}}
|
||||
>
|
||||
<b>{config.website.title ?? 'Zipline'}</b>
|
||||
@@ -262,47 +295,45 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
|
||||
</div>
|
||||
|
||||
{showLocalLogin && (
|
||||
<>
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<Stack my='sm'>
|
||||
<TextInput
|
||||
size='md'
|
||||
placeholder='Enter your username...'
|
||||
styles={{
|
||||
input: {
|
||||
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
|
||||
},
|
||||
}}
|
||||
{...form.getInputProps('username', { withError: true })}
|
||||
/>
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<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')}
|
||||
/>
|
||||
<PasswordInput
|
||||
size='md'
|
||||
placeholder='Enter your password...'
|
||||
styles={{
|
||||
input: {
|
||||
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
|
||||
},
|
||||
}}
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size='md'
|
||||
fullWidth
|
||||
type='submit'
|
||||
loading={isLoading}
|
||||
variant={config.website.loginBackground ? 'outline' : 'filled'}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</>
|
||||
<Button
|
||||
size='md'
|
||||
fullWidth
|
||||
type='submit'
|
||||
loading={!config}
|
||||
variant={config.website.loginBackground ? 'outline' : 'filled'}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<Stack my='xs'>
|
||||
{eitherTrue(config.features.oauthRegistration, config.features.userRegistration) && (
|
||||
{(config.features.oauthRegistration || config.features.userRegistration) && (
|
||||
<Divider label='or' />
|
||||
)}
|
||||
|
||||
@@ -323,7 +354,7 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
|
||||
{config.features.userRegistration && (
|
||||
<Button
|
||||
component={Link}
|
||||
href='/auth/register'
|
||||
to='/auth/register'
|
||||
size='md'
|
||||
fullWidth
|
||||
variant='outline'
|
||||
@@ -332,6 +363,7 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
|
||||
Sign up
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Group grow>
|
||||
{config.oauthEnabled.discord && (
|
||||
<ExternalAuthButton
|
||||
@@ -358,19 +390,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';
|
||||
22
src/pages/auth/logout.tsx → src/client/pages/auth/logout.tsx
Executable file → Normal file
22
src/pages/auth/logout.tsx → src/client/pages/auth/logout.tsx
Executable file → Normal file
@@ -1,35 +1,35 @@
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
export default function Logout() {
|
||||
const router = useRouter();
|
||||
useTitle('Log out');
|
||||
|
||||
const setUser = useUserStore((state) => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const userRes = await fetch('/api/user');
|
||||
|
||||
if (userRes.ok) {
|
||||
const res = await fetch('/api/auth/logout');
|
||||
|
||||
if (res.ok) {
|
||||
setUser(null);
|
||||
mutate('/api/user', null);
|
||||
await router.push('/auth/login');
|
||||
navigate('/auth/login');
|
||||
} else {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
} else {
|
||||
await router.push('/dashboard');
|
||||
navigate('/dashboard');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingOverlay visible />
|
||||
</>
|
||||
);
|
||||
return <LoadingOverlay visible />;
|
||||
}
|
||||
|
||||
Logout.title = 'Logout';
|
||||
281
src/client/pages/auth/register.tsx
Normal file
281
src/client/pages/auth/register.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Image,
|
||||
LoadingOverlay,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications, showNotification } from '@mantine/notifications';
|
||||
import { IconLogin, IconPlus, IconUserPlus, IconX } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import GenericError from '../../error/GenericError';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Register');
|
||||
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
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 {
|
||||
data: invite,
|
||||
error: inviteError,
|
||||
isLoading: inviteLoading,
|
||||
} = useSWR<Response['/api/auth/invites/web']>(
|
||||
location.search.includes('code') ? `/api/auth/invites/web${location.search}` : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
refreshWhenHidden: false,
|
||||
revalidateIfStale: false,
|
||||
},
|
||||
);
|
||||
|
||||
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) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
|
||||
if (!config?.features.userRegistration && !code) {
|
||||
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');
|
||||
navigate('/dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || configLoading) return <LoadingOverlay visible />;
|
||||
|
||||
if (!config || configError) {
|
||||
return (
|
||||
<GenericError
|
||||
title='Error loading configuration'
|
||||
message='Could not load server configuration...'
|
||||
details={configError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (code && inviteError) {
|
||||
if (inviteError) {
|
||||
showNotification({
|
||||
id: 'invalid-invite',
|
||||
message: 'Invalid or expired invite. Please try again later.',
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
navigate('/auth/login');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (inviteLoading) return <LoadingOverlay visible />;
|
||||
}
|
||||
|
||||
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'>
|
||||
You’ve been invited to join <b>{config?.website?.title ?? 'Zipline'}</b>
|
||||
{invite.inviter && (
|
||||
<>
|
||||
{' '}
|
||||
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
58
src/pages/setup.tsx → src/client/pages/auth/setup.tsx
Executable file → Normal file
@@ -1,6 +1,6 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { getZipline } from '@/lib/db/models/zipline';
|
||||
import { type Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
@@ -18,11 +18,8 @@ import {
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconArrowBackUp, IconArrowForwardUp, IconCheck, IconX } from '@tabler/icons-react';
|
||||
import { GetServerSideProps } from 'next';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { redirect, useNavigate } from 'react-router-dom';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
function LinkToDoc({ href, title, children }: { href: string; title: string; children: React.ReactNode }) {
|
||||
@@ -36,8 +33,22 @@ function LinkToDoc({ href, title, children }: { href: string; title: string; chi
|
||||
);
|
||||
}
|
||||
|
||||
export default function Setup() {
|
||||
const router = useRouter();
|
||||
export async function loader() {
|
||||
const res = await fetch('/api/server/public');
|
||||
if (!res.ok) {
|
||||
throw new Response('Failed to fetch server settings', { status: res.status });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.firstSetup) return redirect('/auth/login');
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function Component() {
|
||||
useTitle('Setup');
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [active, setActive] = useState(0);
|
||||
const nextStep = () => setActive((current) => (current < 3 ? current + 1 : current));
|
||||
@@ -99,18 +110,13 @@ export default function Setup() {
|
||||
setActive(2);
|
||||
} else {
|
||||
mutate('/api/user', data as Response['/api/user']);
|
||||
router.push('/dashboard');
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Zipline Setup</title>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
</Head>
|
||||
|
||||
<Paper withBorder p='xs' m='sm'>
|
||||
<Stepper active={active} onStepClick={setActive} m='md'>
|
||||
<Stepper.Step label='Welcome!' description='Setup Zipline'>
|
||||
@@ -145,7 +151,11 @@ export default function Setup() {
|
||||
|
||||
<Text>
|
||||
To see all of the available environment variables, please refer to the documentation{' '}
|
||||
<Anchor component={Link} href='https://zipline.diced.sh/docs/config'>
|
||||
<Anchor
|
||||
href='https://zipline.diced.sh/docs/config'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
here.
|
||||
</Anchor>
|
||||
</Text>
|
||||
@@ -236,20 +246,4 @@ export default function Setup() {
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async () => {
|
||||
const { firstSetup } = await getZipline();
|
||||
|
||||
if (!firstSetup)
|
||||
return {
|
||||
redirect: {
|
||||
destination: '/dashboard',
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
};
|
||||
|
||||
Setup.title = 'Setup';
|
||||
Component.displayName = 'Setup';
|
||||
41
src/client/pages/auth/tos.tsx
Normal file
41
src/client/pages/auth/tos.tsx
Normal 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';
|
||||
10
src/client/pages/dashboard/admin/invites.tsx
Normal file
10
src/client/pages/dashboard/admin/invites.tsx
Normal 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';
|
||||
10
src/client/pages/dashboard/admin/settings.tsx
Normal file
10
src/client/pages/dashboard/admin/settings.tsx
Normal 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';
|
||||
23
src/client/pages/dashboard/admin/users/[id]/files.tsx
Normal file
23
src/client/pages/dashboard/admin/users/[id]/files.tsx
Normal 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';
|
||||
10
src/client/pages/dashboard/admin/users/index.tsx
Normal file
10
src/client/pages/dashboard/admin/users/index.tsx
Normal 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';
|
||||
10
src/client/pages/dashboard/files.tsx
Normal file
10
src/client/pages/dashboard/files.tsx
Normal 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';
|
||||
10
src/client/pages/dashboard/folders.tsx
Normal file
10
src/client/pages/dashboard/folders.tsx
Normal 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';
|
||||
10
src/client/pages/dashboard/index.tsx
Normal file
10
src/client/pages/dashboard/index.tsx
Normal 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/';
|
||||
28
src/client/pages/dashboard/metrics.tsx
Normal file
28
src/client/pages/dashboard/metrics.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import DashboardMetrics from '@/components/pages/metrics';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { redirect } from 'react-router-dom';
|
||||
|
||||
export async function loader() {
|
||||
const configRes = await fetch('/api/server/public');
|
||||
if (!configRes.ok) throw new Error('Failed to get public configuration');
|
||||
|
||||
const config = await configRes.json();
|
||||
if (config.features.metrics?.adminOnly) {
|
||||
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');
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function Component() {
|
||||
useTitle('Metrics');
|
||||
|
||||
return <DashboardMetrics />;
|
||||
}
|
||||
|
||||
Component.displayName = 'Dashboard/Metrics';
|
||||
10
src/client/pages/dashboard/settings.tsx
Normal file
10
src/client/pages/dashboard/settings.tsx
Normal 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';
|
||||
10
src/client/pages/dashboard/upload/file.tsx
Normal file
10
src/client/pages/dashboard/upload/file.tsx
Normal 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';
|
||||
10
src/client/pages/dashboard/upload/text.tsx
Normal file
10
src/client/pages/dashboard/upload/text.tsx
Normal 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';
|
||||
10
src/client/pages/dashboard/urls.tsx
Normal file
10
src/client/pages/dashboard/urls.tsx
Normal 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';
|
||||
57
src/client/pages/folder/[id]/index.tsx
Normal file
57
src/client/pages/folder/[id]/index.tsx
Normal 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';
|
||||
56
src/client/pages/folder/[id]/upload.tsx
Normal file
56
src/client/pages/folder/[id]/upload.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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 { data, 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 data('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';
|
||||
253
src/client/pages/view/[id].tsx
Normal file
253
src/client/pages/view/[id].tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
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;
|
||||
|
||||
// Fix dates that were stringified during SSR
|
||||
if (file?.createdAt) (file as any).createdAt = new Date(file.createdAt);
|
||||
if (file?.updatedAt) (file as any).updatedAt = new Date(file.updatedAt);
|
||||
if (file?.deletesAt) (file as any).deletesAt = new Date(file.deletesAt);
|
||||
if (user?.createdAt) (user as any).createdAt = new Date(user.createdAt);
|
||||
if (user?.updatedAt) (user as any).updatedAt = new Date(user.updatedAt);
|
||||
|
||||
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,
|
||||
user: user as User,
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
65
src/client/pages/view/url/[id].tsx
Normal file
65
src/client/pages/view/url/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
src/client/routes.tsx
Normal file
118
src/client/routes.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import Layout from '@/components/Layout';
|
||||
import { Response as ApiResponse } from '@/lib/api/response';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { createBrowserRouter, data, 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() {
|
||||
try {
|
||||
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'];
|
||||
} catch (error) {
|
||||
throw data('Failed to load settings' + (error as any).message, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
25
src/client/ssr-view-url/client.tsx
Normal file
25
src/client/ssr-view-url/client.tsx
Normal 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>,
|
||||
);
|
||||
12
src/client/ssr-view-url/index.html
Normal file
12
src/client/ssr-view-url/index.html
Normal 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>
|
||||
18
src/client/ssr-view-url/routes.tsx
Normal file
18
src/client/ssr-view-url/routes.tsx
Normal 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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
95
src/client/ssr-view-url/server.tsx
Normal file
95
src/client/ssr-view-url/server.tsx
Normal 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 });
|
||||
}
|
||||
25
src/client/ssr-view/client.tsx
Normal file
25
src/client/ssr-view/client.tsx
Normal 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>,
|
||||
);
|
||||
12
src/client/ssr-view/index.html
Normal file
12
src/client/ssr-view/index.html
Normal 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>
|
||||
18
src/client/ssr-view/routes.tsx
Normal file
18
src/client/ssr-view/routes.tsx
Normal 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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
275
src/client/ssr-view/server.tsx
Normal file
275
src/client/ssr-view/server.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
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: decodeURIComponent(id) },
|
||||
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}`;
|
||||
}
|
||||
|
||||
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)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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)}`,
|
||||
};
|
||||
}
|
||||
@@ -1,21 +1,30 @@
|
||||
import { SafeConfig } from '@/lib/config/safe';
|
||||
import { ApiServerSettingsWebResponse } from '@/server/routes/api/server/settings';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
const ConfigContext = createContext<SafeConfig | null>(null);
|
||||
type ConfigContextType = ApiServerSettingsWebResponse;
|
||||
|
||||
const ConfigContext = createContext<ConfigContextType | null>(null);
|
||||
|
||||
export function useConfig() {
|
||||
const ctx = useContext(ConfigContext);
|
||||
if (!ctx) throw new Error('useConfig must be used within a ConfigProvider');
|
||||
|
||||
return ctx;
|
||||
return ctx.config;
|
||||
}
|
||||
|
||||
export function useCodeMap() {
|
||||
const ctx = useContext(ConfigContext);
|
||||
if (!ctx) throw new Error('useCodeMap must be used within a ConfigProvider');
|
||||
|
||||
return ctx.codeMap;
|
||||
}
|
||||
|
||||
export default function ConfigProvider({
|
||||
config,
|
||||
data,
|
||||
children,
|
||||
}: {
|
||||
config: SafeConfig;
|
||||
data: ConfigContextType;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <ConfigContext.Provider value={config}>{children}</ConfigContext.Provider>;
|
||||
return <ConfigContext.Provider value={data}>{children}</ConfigContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SafeConfig } from '@/lib/config/safe';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import useAvatar from '@/lib/hooks/useAvatar';
|
||||
import useLogin from '@/lib/hooks/useLogin';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import {
|
||||
@@ -44,11 +45,11 @@ import {
|
||||
IconUpload,
|
||||
IconUsersGroup,
|
||||
} from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import ConfigProvider from './ConfigProvider';
|
||||
import VersionBadge from './VersionBadge';
|
||||
import { Link, useLoaderData } from 'react-router-dom';
|
||||
import { dashboardLoader } from '../client/routes';
|
||||
|
||||
type NavLinks = {
|
||||
label: string;
|
||||
@@ -142,14 +143,17 @@ const navLinks: NavLinks[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export default function Layout({ children, config }: { children: React.ReactNode; config: SafeConfig }) {
|
||||
export default function Layout() {
|
||||
const theme = useMantineTheme();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const router = useRouter();
|
||||
const modals = useModals();
|
||||
const clipboard = useClipboard();
|
||||
const setUser = useUserStore((s) => s.setUser);
|
||||
const location = useLocation();
|
||||
|
||||
const loaderData = useLoaderData<typeof dashboardLoader>();
|
||||
const config = loaderData.config;
|
||||
|
||||
const { user, mutate } = useLogin();
|
||||
const { avatar } = useAvatar();
|
||||
@@ -275,7 +279,8 @@ export default function Layout({ children, config }: { children: React.ReactNode
|
||||
<Menu.Item
|
||||
leftSection={<IconSettingsFilled size='1rem' />}
|
||||
component={Link}
|
||||
href='/dashboard/settings'
|
||||
to='/dashboard/settings'
|
||||
prefetch='intent'
|
||||
>
|
||||
Settings
|
||||
</Menu.Item>
|
||||
@@ -284,7 +289,8 @@ export default function Layout({ children, config }: { children: React.ReactNode
|
||||
<Menu.Item
|
||||
leftSection={<IconAdjustments size='1rem' />}
|
||||
component={Link}
|
||||
href='/dashboard/admin/settings'
|
||||
to='/dashboard/admin/settings'
|
||||
prefetch='intent'
|
||||
>
|
||||
Server Settings
|
||||
</Menu.Item>
|
||||
@@ -295,7 +301,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
|
||||
color='red'
|
||||
leftSection={<IconLogout size='1rem' />}
|
||||
component={Link}
|
||||
href='/auth/logout'
|
||||
to='/auth/logout'
|
||||
>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
@@ -322,9 +328,10 @@ export default function Layout({ children, config }: { children: React.ReactNode
|
||||
leftSection={link.icon}
|
||||
variant='light'
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
active={router.pathname === link.href}
|
||||
active={location.pathname === link.href}
|
||||
component={Link}
|
||||
href={link.href || ''}
|
||||
to={link.href || ''}
|
||||
prefetch='intent'
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
@@ -335,7 +342,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
|
||||
leftSection={link.icon}
|
||||
variant='light'
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
defaultOpened={link.active(router.pathname)}
|
||||
defaultOpened={link.active(location.pathname)}
|
||||
>
|
||||
{link.links
|
||||
.filter(
|
||||
@@ -348,9 +355,10 @@ export default function Layout({ children, config }: { children: React.ReactNode
|
||||
leftSection={sublink.icon}
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
variant='light'
|
||||
active={router.pathname === sublink.href}
|
||||
active={location.pathname === sublink.href}
|
||||
component={Link}
|
||||
href={sublink.href || ''}
|
||||
to={sublink.href || ''}
|
||||
prefetch='intent'
|
||||
/>
|
||||
))}
|
||||
</NavLink>
|
||||
@@ -372,7 +380,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
|
||||
leftSection={<IconExternalLink size='1rem' />}
|
||||
variant='light'
|
||||
component={Link}
|
||||
href={url}
|
||||
to={url}
|
||||
target='_blank'
|
||||
/>
|
||||
))}
|
||||
@@ -382,9 +390,9 @@ export default function Layout({ children, config }: { children: React.ReactNode
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
<ConfigProvider config={config}>
|
||||
<ConfigProvider data={loaderData}>
|
||||
<Paper m='lg' withBorder p='xs'>
|
||||
{children}
|
||||
<Outlet />
|
||||
</Paper>
|
||||
</ConfigProvider>
|
||||
</AppShell.Main>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Config } from '@/lib/config/validate';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
@@ -6,6 +7,7 @@ import dark_blue from '@/lib/theme/builtins/dark_blue';
|
||||
import { MantineProvider, createTheme } from '@mantine/core';
|
||||
import { useColorScheme } from '@mantine/hooks';
|
||||
import { createContext, useContext } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
const ThemeContext = createContext<{
|
||||
@@ -21,15 +23,25 @@ export function useThemes() {
|
||||
return ctx.themes;
|
||||
}
|
||||
|
||||
export default function Theming({
|
||||
themes,
|
||||
defaultTheme,
|
||||
export default function ThemeProvider({
|
||||
ssrThemes,
|
||||
ssrDefaultTheme,
|
||||
children,
|
||||
}: {
|
||||
themes: ZiplineTheme[];
|
||||
ssrThemes?: ZiplineTheme[];
|
||||
ssrDefaultTheme?: Config['website']['theme'];
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Config['website']['theme'];
|
||||
}) {
|
||||
const { data: clientThemes } = useSWR<Response['/api/server/themes']>('/api/server/themes', {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
refreshWhenHidden: false,
|
||||
revalidateIfStale: false,
|
||||
});
|
||||
|
||||
const themes = ssrThemes ?? clientThemes?.themes;
|
||||
const defaultTheme = ssrDefaultTheme ?? clientThemes?.defaultTheme;
|
||||
|
||||
const user = useUserStore((state) => state.user);
|
||||
const [userTheme, preferredDark, preferredLight] = useSettingsStore(
|
||||
useShallow((state) => [state.settings.theme, state.settings.themeDark, state.settings.themeLight]),
|
||||
@@ -53,17 +65,21 @@ export default function Theming({
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ themes }}>
|
||||
<MantineProvider
|
||||
defaultColorScheme={theme.colorScheme as unknown as any}
|
||||
forceColorScheme={theme.colorScheme as unknown as any}
|
||||
theme={createTheme({
|
||||
...themeComponents(theme),
|
||||
defaultRadius: 'md',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
</ThemeContext.Provider>
|
||||
<>
|
||||
{theme?.extraCss && <style>{theme.extraCss}</style>}
|
||||
|
||||
<ThemeContext.Provider value={{ themes: themes ?? [] }}>
|
||||
<MantineProvider
|
||||
defaultColorScheme={theme.colorScheme as unknown as any}
|
||||
forceColorScheme={theme.colorScheme as unknown as any}
|
||||
theme={createTheme({
|
||||
...themeComponents(theme),
|
||||
defaultRadius: 'md',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
</ThemeContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
19
src/components/ZiplineSSRProvider.tsx
Normal file
19
src/components/ZiplineSSRProvider.tsx
Normal 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>;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { fetchApi } from '@/lib/fetchApi';
|
||||
import { Button, Divider, Modal, NumberInput, PasswordInput, Stack, TextInput } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconEye, IconKey, IconPencil, IconPencilOff, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { mutateFiles } from '../actions';
|
||||
|
||||
export default function EditFileDetailsModal({
|
||||
@@ -17,6 +17,7 @@ export default function EditFileDetailsModal({
|
||||
}) {
|
||||
if (!file) return null;
|
||||
|
||||
const [name, setName] = useState<string>(file.name ?? '');
|
||||
const [maxViews, setMaxViews] = useState<number | null>(file?.maxViews ?? null);
|
||||
const [password, setPassword] = useState<string | null>('');
|
||||
const [originalName, setOriginalName] = useState<string | null>(file?.originalName ?? null);
|
||||
@@ -54,12 +55,16 @@ export default function EditFileDetailsModal({
|
||||
password?: string;
|
||||
originalName?: string;
|
||||
type?: string;
|
||||
name?: string;
|
||||
} = {};
|
||||
|
||||
if (maxViews !== null) data['maxViews'] = maxViews;
|
||||
if (password !== null) data['password'] = password?.trim();
|
||||
if (originalName !== null) data['originalName'] = originalName?.trim();
|
||||
if (type !== null) data['type'] = type?.trim();
|
||||
if (name !== file.name) data['name'] = name.trim();
|
||||
|
||||
const passwordTrimmed = password?.trim();
|
||||
if (passwordTrimmed !== '') data['password'] = passwordTrimmed;
|
||||
|
||||
const { error } = await fetchApi(`/api/user/files/${file.id}`, 'PATCH', data);
|
||||
|
||||
@@ -85,9 +90,26 @@ export default function EditFileDetailsModal({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(file.name ?? '');
|
||||
setMaxViews(file.maxViews ?? null);
|
||||
setPassword(file.password ? '' : null);
|
||||
setOriginalName(file.originalName ?? null);
|
||||
setType(file.type ?? null);
|
||||
}
|
||||
}, [open, file]);
|
||||
|
||||
return (
|
||||
<Modal zIndex={300} title={`Editing "${file.name}"`} onClose={onClose} opened={open}>
|
||||
<Stack gap='xs' my='sm'>
|
||||
<TextInput
|
||||
label='Name'
|
||||
description='Rename the file.'
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value.trim())}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label='Max Views'
|
||||
placeholder='Unlimited'
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
Modal,
|
||||
Pill,
|
||||
PillsInput,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
Title,
|
||||
@@ -61,8 +60,8 @@ import {
|
||||
removeFromFolder,
|
||||
viewFile,
|
||||
} from '../actions';
|
||||
import FileStat from './FileStat';
|
||||
import EditFileDetailsModal from './EditFileDetailsModal';
|
||||
import FileStat from './FileStat';
|
||||
|
||||
function ActionButton({
|
||||
Icon,
|
||||
@@ -189,9 +188,9 @@ export default function FileModal({
|
||||
</Text>
|
||||
}
|
||||
size='auto'
|
||||
maw='90vw'
|
||||
centered
|
||||
zIndex={200}
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
>
|
||||
{file ? (
|
||||
<>
|
||||
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { Icon, IconFileUnknown, IconPlayerPlay, IconShieldLockFilled } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { renderMode } from '../pages/upload/renderMode';
|
||||
import Asciinema from '../render/Asciinema';
|
||||
import Pdf from '../render/Pdf';
|
||||
import Render from '../render/Render';
|
||||
import fileIcon from './fileIcon';
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
|
||||
function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
|
||||
return (
|
||||
@@ -30,7 +32,7 @@ function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
|
||||
|
||||
function Placeholder({ text, Icon, ...props }: { text: string; Icon: Icon; onClick?: () => void }) {
|
||||
return (
|
||||
<Center py='xs' style={{ height: '100%', width: '100%', cursor: 'pointed' }} {...props}>
|
||||
<Center py='xs' style={{ height: '100%', width: '100%', cursor: 'pointer' }} {...props}>
|
||||
<PlaceholderContent text={text} Icon={Icon} />
|
||||
</Center>
|
||||
);
|
||||
@@ -78,62 +80,67 @@ export default function DashboardFileType({
|
||||
code?: boolean;
|
||||
allowZoom?: boolean;
|
||||
}) {
|
||||
const [overrideType] = useQueryState('otype', parseAsStringLiteral(['video', 'audio', 'image', 'text']));
|
||||
|
||||
const user = useUserStore((state) => state.user);
|
||||
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
|
||||
|
||||
const fileRoute = user ? `/api/user/files/${(file as DbFile).id}/raw` : `/raw/${file.name}`;
|
||||
const thumbnailRoute = user
|
||||
? `/api/user/files/${(file as DbFile).thumbnail?.path}/raw`
|
||||
: `/raw/${(file as DbFile).thumbnail?.path}`;
|
||||
const dbFile = 'id' in file;
|
||||
const renderIn = renderMode(file.name.split('.').pop() || '');
|
||||
const renderIn = useMemo(() => renderMode(file.name.split('.').pop() || ''), [file.name]);
|
||||
|
||||
const [fileContent, setFileContent] = useState('');
|
||||
const [type, setType] = useState<string>(file.type.split('/')[0]);
|
||||
const [type, setType] = useState(file.type.split('/')[0]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const gettext = async () => {
|
||||
if (!dbFile) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if ((reader.result! as string).length > 1 * 1024 * 1024) {
|
||||
setFileContent(
|
||||
reader.result!.slice(0, 1 * 1024 * 1024) +
|
||||
'\n...\nThe file is too big to display click the download icon to view/download it.',
|
||||
);
|
||||
} else {
|
||||
setFileContent(reader.result as string);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
const getText = useCallback(async () => {
|
||||
try {
|
||||
if (!dbFile) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if ((reader.result! as string).length > 1 * 1024 * 1024) {
|
||||
setFileContent(
|
||||
reader.result!.slice(0, 1 * 1024 * 1024) +
|
||||
'\n...\nThe file is too big to display click the download icon to view/download it.',
|
||||
);
|
||||
} else {
|
||||
setFileContent(reader.result as string);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 1 * 1024 * 1024) {
|
||||
const res = await fetch(`/raw/${file.name}${password ? `?pw=${password}` : ''}`, {
|
||||
headers: {
|
||||
Range: 'bytes=0-' + 1 * 1024 * 1024, // 0 mb to 1 mb
|
||||
},
|
||||
});
|
||||
if (file.size > 1 * 1024 * 1024) {
|
||||
const res = await fetch(`${fileRoute}${password ? `?pw=${password}` : ''}`, {
|
||||
headers: {
|
||||
Range: 'bytes=0-' + 1 * 1024 * 1024, // 0 mb to 1 mb
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch file');
|
||||
const text = await res.text();
|
||||
setFileContent(
|
||||
text + '\n...\nThe file is too big to display click the download icon to view/download it.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`${fileRoute}${password ? `?pw=${password}` : ''}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch file');
|
||||
const text = await res.text();
|
||||
setFileContent(
|
||||
text + '\n...\nThe file is too big to display click the download icon to view/download it.',
|
||||
);
|
||||
return;
|
||||
setFileContent(text);
|
||||
} catch {
|
||||
setFileContent('Error loading file.');
|
||||
}
|
||||
|
||||
const res = await fetch(`/raw/${file.name}${password ? `?pw=${password}` : ''}`);
|
||||
const text = await res.text();
|
||||
|
||||
setFileContent(text);
|
||||
};
|
||||
}, [dbFile, file, password]);
|
||||
|
||||
useEffect(() => {
|
||||
if (code) {
|
||||
setType('text');
|
||||
gettext();
|
||||
} else if (overrideType === 'text' || type === 'text') {
|
||||
gettext();
|
||||
getText();
|
||||
} else if (type === 'text') {
|
||||
getText();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@@ -164,20 +171,22 @@ export default function DashboardFileType({
|
||||
</Paper>
|
||||
);
|
||||
|
||||
switch (overrideType || type) {
|
||||
case 'video':
|
||||
const isAsciicast = file.type === 'application/x-asciicast' || file.name.endsWith('.cast');
|
||||
|
||||
switch (true) {
|
||||
case type === 'video':
|
||||
return show ? (
|
||||
<video
|
||||
width='100%'
|
||||
autoPlay
|
||||
muted
|
||||
controls
|
||||
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
style={{ cursor: 'pointer', maxWidth: '85vw', maxHeight: '85vh' }}
|
||||
/>
|
||||
) : (file as DbFile).thumbnail && dbFile ? (
|
||||
<Box pos='relative'>
|
||||
<MantineImage src={`/raw/${(file as DbFile).thumbnail!.path}`} alt={file.name} />
|
||||
<MantineImage src={thumbnailRoute} alt={file.name || 'Video thumbnail'} />
|
||||
|
||||
<Center
|
||||
pos='absolute'
|
||||
@@ -198,12 +207,13 @@ export default function DashboardFileType({
|
||||
) : (
|
||||
<Placeholder text={`Click to play video ${file.name}`} Icon={fileIcon(file.type)} />
|
||||
);
|
||||
case 'image':
|
||||
|
||||
case type === 'image':
|
||||
return show ? (
|
||||
<Center>
|
||||
<MantineImage
|
||||
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
alt={file.name}
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
alt={file.name || 'Image'}
|
||||
style={{
|
||||
cursor: allowZoom ? 'zoom-in' : 'default',
|
||||
maxWidth: '70vw',
|
||||
@@ -214,10 +224,8 @@ export default function DashboardFileType({
|
||||
{allowZoom && open && (
|
||||
<FileZoomModal setOpen={setOpen}>
|
||||
<MantineImage
|
||||
src={
|
||||
dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)
|
||||
}
|
||||
alt={file.name}
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
alt={file.name || 'Image'}
|
||||
style={{
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '95vh',
|
||||
@@ -233,23 +241,25 @@ export default function DashboardFileType({
|
||||
<MantineImage
|
||||
fit='contain'
|
||||
mah={400}
|
||||
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
alt={file.name}
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
alt={file.name || 'Image'}
|
||||
/>
|
||||
);
|
||||
case 'audio':
|
||||
|
||||
case type === 'audio':
|
||||
return show ? (
|
||||
<audio
|
||||
autoPlay
|
||||
muted
|
||||
controls
|
||||
style={{ width: '100%' }}
|
||||
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
/>
|
||||
) : (
|
||||
<Placeholder text={`Click to play audio ${file.name}`} Icon={fileIcon(file.type)} />
|
||||
);
|
||||
case 'text':
|
||||
|
||||
case type === 'text':
|
||||
return show ? (
|
||||
fileContent.trim() === '' ? (
|
||||
<LoadingOverlay
|
||||
@@ -273,6 +283,24 @@ export default function DashboardFileType({
|
||||
) : (
|
||||
<Placeholder text={`Click to view text ${file.name}`} Icon={fileIcon(file.type)} />
|
||||
);
|
||||
|
||||
case isAsciicast === true:
|
||||
return show && dbFile ? (
|
||||
<Asciinema src={`${fileRoute}${password ? `?pw=${password}` : ''}`} />
|
||||
) : (
|
||||
<Placeholder
|
||||
text={`Click to download asciinema cast ${file.name}`}
|
||||
Icon={fileIcon('application/x-asciicast')}
|
||||
/>
|
||||
);
|
||||
|
||||
case file.type === 'application/pdf':
|
||||
return show && dbFile ? (
|
||||
<Pdf src={`${fileRoute}${password ? `?pw=${password}` : ''}`} />
|
||||
) : (
|
||||
<Placeholder text={`Click to view PDF ${file.name}`} Icon={fileIcon(file.type)} />
|
||||
);
|
||||
|
||||
default:
|
||||
if (dbFile && !show)
|
||||
return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
|
||||
@@ -281,7 +309,7 @@ export default function DashboardFileType({
|
||||
return (
|
||||
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
|
||||
<Placeholder
|
||||
onClick={() => window.open(`/raw/${file.name}${password ? `?pw=${password}` : ''}`)}
|
||||
onClick={() => window.open(`${fileRoute}${password ? `?pw=${password}` : ''}`)}
|
||||
text={`Click to view file ${file.name} in a new tab`}
|
||||
Icon={fileIcon(file.type)}
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
IconTrashFilled,
|
||||
IconTrashXFilled,
|
||||
} from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
export function viewFile(file: File) {
|
||||
@@ -37,7 +37,7 @@ export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>)
|
||||
notifications.show({
|
||||
title: 'Copied link',
|
||||
message: (
|
||||
<Anchor component={Link} href={url}>
|
||||
<Anchor component={Link} to={url}>
|
||||
{url}
|
||||
</Anchor>
|
||||
),
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
IconFileTypeHtml,
|
||||
IconFileTypeJs,
|
||||
IconFileTypeJsx,
|
||||
IconFileTypePdf,
|
||||
IconFileTypePhp,
|
||||
IconFileTypePpt,
|
||||
IconFileTypeRs,
|
||||
@@ -49,7 +50,7 @@ const icons: Record<string, Icon> = {
|
||||
'application/x-gzip': IconFileZip,
|
||||
|
||||
// common text/document files that are not detected by the 'text' type
|
||||
'application/pdf': IconFileText,
|
||||
'application/pdf': IconFileTypePdf,
|
||||
'application/msword': IconFileTypeDocx,
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': IconFileTypeDocx,
|
||||
'application/vnd.ms-excel': IconFileTypeXls,
|
||||
@@ -67,6 +68,7 @@ const icons: Record<string, Icon> = {
|
||||
'text/javascript': IconFileTypeJs,
|
||||
'application/json': IconBracketsContain,
|
||||
'text/xml': IconFileTypeXml,
|
||||
'application/x-asciicast': IconTerminal2,
|
||||
|
||||
// zipline text uploads
|
||||
'text/x-zipline-html': IconFileTypeHtml,
|
||||
|
||||
@@ -4,18 +4,15 @@ import { bytes } from '@/lib/bytes';
|
||||
import useLogin from '@/lib/hooks/useLogin';
|
||||
import { Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text, Title } from '@mantine/core';
|
||||
import { IconDeviceSdCard, IconEyeFilled, IconFiles, IconLink, IconStarFilled } from '@tabler/icons-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const DashboardFile = dynamic(() => import('@/components/file/DashboardFile'), {
|
||||
loading: () => <Skeleton height={350} animate />,
|
||||
});
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
|
||||
export default function DashboardHome() {
|
||||
const { user } = useLogin();
|
||||
const { data: recent, isLoading: recentLoading } = useSWR<Response['/api/user/recent']>('/api/user/recent');
|
||||
const { data: stats, isLoading: statsLoading } = useSWR<Response['/api/user/stats']>('/api/user/stats');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>
|
||||
@@ -63,7 +60,9 @@ export default function DashboardHome() {
|
||||
) : recent?.length !== 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 'sm', md: 'md' }}>
|
||||
{recent!.map((file, i) => (
|
||||
<DashboardFile key={i} file={file} />
|
||||
<Suspense fallback={<Skeleton height={350} animate />} key={i}>
|
||||
<DashboardFile file={file} />
|
||||
</Suspense>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
|
||||
@@ -3,10 +3,9 @@ import { IncompleteFile } from '@/lib/db/models/incompleteFile';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { ActionIcon, Badge, Button, Card, Group, Modal, Paper, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IncompleteFileStatus } from '../../../../generated/client';
|
||||
import { IncompleteFileStatus } from '@/prisma/client';
|
||||
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
|
||||
@@ -33,7 +32,7 @@ const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
|
||||
};
|
||||
|
||||
export default function PendingFilesButton() {
|
||||
const [open, setOpen] = useQueryState('popen', parseAsBoolean.withDefault(false));
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data: incompleteFiles, mutate } = useSWR<
|
||||
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
|
||||
|
||||
99
src/components/pages/files/TableEditModal.tsx
Normal file
99
src/components/pages/files/TableEditModal.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { FieldSettings, useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button, Checkbox, Group, Modal, Paper, Text } from '@mantine/core';
|
||||
import { IconGripVertical } from '@tabler/icons-react';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
export const NAMES = {
|
||||
name: 'Name',
|
||||
originalName: 'Original Name',
|
||||
tags: 'Tags',
|
||||
type: 'Type',
|
||||
size: 'Size',
|
||||
createdAt: 'Created At',
|
||||
favorite: 'Favorite',
|
||||
views: 'Views',
|
||||
};
|
||||
|
||||
function SortableTableField({ item }: { item: FieldSettings }) {
|
||||
const setVisible = useFileTableSettingsStore((state) => state.setVisible);
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: item.field,
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
cursor: 'grab',
|
||||
width: '100%',
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper withBorder p='xs' ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
<Group gap='xs'>
|
||||
<IconGripVertical size='1rem' />
|
||||
|
||||
<Checkbox checked={item.visible} onChange={() => setVisible(item.field, !item.visible)} />
|
||||
|
||||
<Text>{NAMES[item.field]}</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TableEditModal({ opened, onCLose }: { opened: boolean; onCLose: () => void }) {
|
||||
const [fields, setIndex, reset] = useFileTableSettingsStore(
|
||||
useShallow((state) => [state.fields, state.setIndex, state.reset]),
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (active.id !== over?.id) {
|
||||
const newIndex = fields.findIndex((item) => item.field === over?.id);
|
||||
|
||||
setIndex(active.id as FieldSettings['field'], newIndex);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onCLose} title='Table Options' centered>
|
||||
<Text mb='md' size='sm' c='dimmed'>
|
||||
Select and drag fields below to make them appear/disappear/reorder in the file table view.
|
||||
</Text>
|
||||
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={fields.map((item) => item.field)} strategy={verticalListSortingStrategy}>
|
||||
{fields.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}
|
||||
>
|
||||
<SortableTableField item={item} />
|
||||
</div>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<Button fullWidth color='red' onClick={() => reset()} variant='light' mt='md'>
|
||||
Reset to Default
|
||||
</Button>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export async function bulkDelete(ids: string[], setSelectedFiles: (files: File[]
|
||||
icon: <IconFilesOff size='1rem' />,
|
||||
id: 'bulk-delete',
|
||||
autoClose: true,
|
||||
loading: false,
|
||||
});
|
||||
} else if (data) {
|
||||
notifications.update({
|
||||
@@ -107,6 +108,7 @@ export async function bulkFavorite(ids: string[]) {
|
||||
icon: <IconStarsOff size='1rem' />,
|
||||
id: 'bulk-favorite',
|
||||
autoClose: true,
|
||||
loading: false,
|
||||
});
|
||||
} else if (data) {
|
||||
notifications.update({
|
||||
@@ -116,6 +118,7 @@ export async function bulkFavorite(ids: string[]) {
|
||||
icon: <IconStarsFilled size='1rem' />,
|
||||
id: 'bulk-favorite',
|
||||
autoClose: true,
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ import FileTable from './views/FileTable';
|
||||
import Files from './views/Files';
|
||||
import TagsButton from './tags/TagsButton';
|
||||
import PendingFilesButton from './PendingFilesButton';
|
||||
import Link from 'next/link';
|
||||
import { IconFileUpload } from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function DashbaordFiles() {
|
||||
export default function DashboardFiles() {
|
||||
const view = useViewStore((state) => state.files);
|
||||
|
||||
return (
|
||||
@@ -18,7 +18,7 @@ export default function DashbaordFiles() {
|
||||
<Title>Files</Title>
|
||||
|
||||
<Tooltip label='Upload a file'>
|
||||
<Link href='/dashboard/upload/file'>
|
||||
<Link to='/dashboard/upload/file'>
|
||||
<ActionIcon variant='outline'>
|
||||
<IconFileUpload size='1rem' />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { fetchApi } from '@/lib/fetchApi';
|
||||
import { ActionIcon, Group, Modal, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconPencil, IconPlus, IconTagOff, IconTags, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import CreateTagModal from './CreateTagModal';
|
||||
@@ -13,8 +12,8 @@ import EditTagModal from './EditTagModal';
|
||||
import TagPill from './TagPill';
|
||||
|
||||
export default function TagsButton() {
|
||||
const [open, setOpen] = useQueryState('topen', parseAsBoolean.withDefault(false));
|
||||
const [createModalOpen, setCreateModalOpen] = useQueryState('ctopen', parseAsBoolean.withDefault(false));
|
||||
const [open, setOpen] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
|
||||
|
||||
const { data: tags, mutate } = useSWR<Extract<Tag[], Response['/api/user/tags']>>('/api/user/tags');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
@@ -12,17 +13,14 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Link from 'next/link';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
const DashboardFile = dynamic(() => import('@/components/file/DashboardFile'), {
|
||||
loading: () => <Skeleton height={350} animate />,
|
||||
});
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
|
||||
export default function FavoriteFiles() {
|
||||
const [page, setPage] = useQueryState('fpage', parseAsInteger.withDefault(1));
|
||||
const [page, setPage] = useQueryState('fpage', 1);
|
||||
|
||||
const { data, isLoading } = useApiPagination({
|
||||
page,
|
||||
@@ -55,7 +53,11 @@ export default function FavoriteFiles() {
|
||||
<LoadingOverlay visible />
|
||||
</Paper>
|
||||
) : (data?.page.length ?? 0 > 0) ? (
|
||||
data?.page.map((file) => <DashboardFile key={file.id} file={file} />)
|
||||
data?.page.map((file) => (
|
||||
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
|
||||
<DashboardFile file={file} />
|
||||
</Suspense>
|
||||
))
|
||||
) : (
|
||||
<Paper withBorder p='sm'>
|
||||
<Center>
|
||||
@@ -69,7 +71,7 @@ export default function FavoriteFiles() {
|
||||
size='compact-sm'
|
||||
leftSection={<IconFileUpload size='1rem' />}
|
||||
component={Link}
|
||||
href='/dashboard/upload/file'
|
||||
to='/dashboard/upload/file'
|
||||
>
|
||||
Upload a file
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
import FileModal from '@/components/file/DashboardFile/FileModal';
|
||||
import { addMultipleToFolder, copyFile, deleteFile } from '@/components/file/actions';
|
||||
import { addMultipleToFolder, copyFile, deleteFile, downloadFile } from '@/components/file/actions';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { type File } from '@/lib/db/models/file';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import { useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -30,21 +31,25 @@ import {
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import {
|
||||
IconCopy,
|
||||
IconDownload,
|
||||
IconExternalLink,
|
||||
IconFile,
|
||||
IconGridPatternFilled,
|
||||
IconStar,
|
||||
IconTableOptions,
|
||||
IconTrashFilled,
|
||||
} from '@tabler/icons-react';
|
||||
import { DataTable } from 'mantine-datatable';
|
||||
import Link from 'next/link';
|
||||
import { parseAsBoolean, parseAsInteger, parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||
import { useEffect, useReducer, useState } from 'react';
|
||||
import { lazy, useEffect, useReducer, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import TableEditModal, { NAMES } from '../TableEditModal';
|
||||
import { bulkDelete, bulkFavorite } from '../bulk';
|
||||
import TagPill from '../tags/TagPill';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
|
||||
const FileModal = lazy(() => import('@/components/file/DashboardFile/FileModal'));
|
||||
|
||||
type ReducerQuery = {
|
||||
state: { name: string; originalName: string; type: string; tags: string; id: string };
|
||||
action: { field: string; query: string };
|
||||
@@ -52,13 +57,6 @@ type ReducerQuery = {
|
||||
|
||||
const PER_PAGE_OPTIONS = [10, 20, 50];
|
||||
|
||||
const NAMES = {
|
||||
name: 'Name',
|
||||
originalName: 'Original name',
|
||||
type: 'Type',
|
||||
id: 'ID',
|
||||
};
|
||||
|
||||
function SearchFilter({
|
||||
setSearchField,
|
||||
searchQuery,
|
||||
@@ -86,8 +84,8 @@ function SearchFilter({
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
label={NAMES[field]}
|
||||
placeholder={`Search by ${NAMES[field].toLowerCase()}`}
|
||||
label={NAMES[field as keyof typeof NAMES]}
|
||||
placeholder={`Search by ${NAMES[field as keyof typeof NAMES].toLowerCase()}`}
|
||||
value={searchQuery[field]}
|
||||
onChange={onChange}
|
||||
size='sm'
|
||||
@@ -181,34 +179,32 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
const clipboard = useClipboard();
|
||||
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
|
||||
|
||||
const [tableEditOpen, setTableEditOpen] = useState(false);
|
||||
|
||||
const fields = useFileTableSettingsStore((state) => state.fields);
|
||||
|
||||
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
|
||||
'/api/user/folders?noincl=true',
|
||||
);
|
||||
|
||||
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
||||
const [perpage, setPerpage] = useState<number>(20);
|
||||
const [sort, setSort] = useQueryState(
|
||||
'sort',
|
||||
parseAsStringLiteral([
|
||||
'id',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deletesAt',
|
||||
'name',
|
||||
'originalName',
|
||||
'size',
|
||||
'type',
|
||||
'views',
|
||||
'favorite',
|
||||
]).withDefault('createdAt'),
|
||||
);
|
||||
const [order, setOrder] = useQueryState<'asc' | 'desc'>(
|
||||
'order',
|
||||
parseAsStringLiteral(['asc', 'desc']).withDefault('desc'),
|
||||
);
|
||||
const [page, setPage] = useQueryState('page', 1);
|
||||
const [perpage, setPerpage] = useState(20);
|
||||
const [sort, setSort] = useState<
|
||||
| 'id'
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
| 'deletesAt'
|
||||
| 'name'
|
||||
| 'originalName'
|
||||
| 'size'
|
||||
| 'type'
|
||||
| 'views'
|
||||
| 'favorite'
|
||||
>('createdAt');
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
||||
const [idSearchOpen, setIdSearchOpen] = useQueryState('idsearch', parseAsBoolean.withDefault(false));
|
||||
const [idSearchOpen, setIdSearchOpen] = useState(false);
|
||||
const [searchField, setSearchField] = useState<'name' | 'originalName' | 'type' | 'tags' | 'id'>('name');
|
||||
const [searchQuery, setSearchQuery] = useReducer(
|
||||
(state: ReducerQuery['state'], action: ReducerQuery['action']) => {
|
||||
@@ -268,6 +264,100 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
}),
|
||||
});
|
||||
|
||||
const FIELDS = [
|
||||
{
|
||||
accessor: 'name',
|
||||
sortable: true,
|
||||
filter: (
|
||||
<SearchFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
field='name'
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'name' && searchQuery.name.trim() !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'originalName',
|
||||
sortable: true,
|
||||
filter: (
|
||||
<SearchFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
field='originalName'
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'originalName' && searchQuery.originalName.trim() !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'tags',
|
||||
sortable: false,
|
||||
width: 200,
|
||||
render: (file: File) => (
|
||||
<ScrollArea w={180} onClick={(e) => e.stopPropagation()}>
|
||||
<Flex gap='sm'>
|
||||
{file.tags!.map((tag) => (
|
||||
<TagPill tag={tag} key={tag.id} />
|
||||
))}
|
||||
</Flex>
|
||||
</ScrollArea>
|
||||
),
|
||||
filter: (
|
||||
<TagsFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'tags' && searchQuery.tags.trim() !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'type',
|
||||
sortable: true,
|
||||
filter: (
|
||||
<SearchFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
field='type'
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'type' && searchQuery.type.trim() !== '',
|
||||
},
|
||||
{ accessor: 'size', sortable: true, render: (file: File) => bytes(file.size) },
|
||||
{
|
||||
accessor: 'createdAt',
|
||||
sortable: true,
|
||||
render: (file: File) => <RelativeDate date={file.createdAt} />,
|
||||
},
|
||||
{
|
||||
accessor: 'favorite',
|
||||
sortable: true,
|
||||
render: (file: File) => (file.favorite ? <Text c='yellow'>Yes</Text> : 'No'),
|
||||
},
|
||||
{
|
||||
accessor: 'views',
|
||||
sortable: true,
|
||||
render: (file: File) => file.views,
|
||||
},
|
||||
{
|
||||
accessor: 'id',
|
||||
hidden: searchField !== 'id' || searchQuery.id.trim() === '',
|
||||
filtering: searchField === 'id' && searchQuery.id.trim() !== '',
|
||||
},
|
||||
];
|
||||
|
||||
const visibleFields = fields.filter((f) => f.visible).map((f) => f.field);
|
||||
const columns = FIELDS.filter((f) => visibleFields.includes(f.accessor as any));
|
||||
columns.sort((a, b) => {
|
||||
const aIndex = fields.findIndex((f) => f.field === a.accessor);
|
||||
const bIndex = fields.findIndex((f) => f.field === b.accessor);
|
||||
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data && selectedFile) {
|
||||
const file = data.page.find((x) => x.id === selectedFile.id);
|
||||
@@ -299,20 +389,32 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
file={selectedFile}
|
||||
/>
|
||||
|
||||
<TableEditModal opened={tableEditOpen} onCLose={() => setTableEditOpen(false)} />
|
||||
|
||||
<Box>
|
||||
<Tooltip label='Search by ID'>
|
||||
<ActionIcon
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
setIdSearchOpen((open) => !open);
|
||||
}}
|
||||
color='blue'
|
||||
// lol if it works it works :shrug:
|
||||
style={{ position: 'relative', top: '-36.4px', left: '219px', margin: 0 }}
|
||||
>
|
||||
<IconGridPatternFilled size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Group>
|
||||
<Tooltip label='Table Options'>
|
||||
<ActionIcon
|
||||
variant='outline'
|
||||
onClick={() => setTableEditOpen((open) => !open)}
|
||||
style={{ position: 'relative', top: '-36.4px', left: '221px', margin: 0 }}
|
||||
>
|
||||
<IconTableOptions size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Search by ID'>
|
||||
<ActionIcon
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
setIdSearchOpen((open) => !open);
|
||||
}}
|
||||
// lol if it works it works :shrug:
|
||||
style={{ position: 'relative', top: '-36.4px', left: '221px', margin: 0 }}
|
||||
>
|
||||
<IconGridPatternFilled size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Collapse in={selectedFiles.length > 0}>
|
||||
<Paper withBorder p='sm' my='sm'>
|
||||
@@ -422,75 +524,7 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
minHeight={200}
|
||||
records={data?.page ?? []}
|
||||
columns={[
|
||||
{
|
||||
accessor: 'name',
|
||||
sortable: true,
|
||||
filter: (
|
||||
<SearchFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
field='name'
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'name' && searchQuery.name.trim() !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'tags',
|
||||
sortable: false,
|
||||
width: 200,
|
||||
render: (file) => (
|
||||
<ScrollArea w={180} onClick={(e) => e.stopPropagation()}>
|
||||
<Flex gap='sm'>
|
||||
{file.tags!.map((tag) => (
|
||||
<TagPill tag={tag} key={tag.id} />
|
||||
))}
|
||||
</Flex>
|
||||
</ScrollArea>
|
||||
),
|
||||
filter: (
|
||||
<TagsFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'tags' && searchQuery.tags.trim() !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'type',
|
||||
sortable: true,
|
||||
filter: (
|
||||
<SearchFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
field='type'
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'type' && searchQuery.type.trim() !== '',
|
||||
},
|
||||
{ accessor: 'size', sortable: true, render: (file) => bytes(file.size) },
|
||||
{
|
||||
accessor: 'createdAt',
|
||||
sortable: true,
|
||||
render: (file) => <RelativeDate date={file.createdAt} />,
|
||||
},
|
||||
{
|
||||
accessor: 'favorite',
|
||||
sortable: true,
|
||||
render: (file) => (file.favorite ? <Text c='yellow'>Yes</Text> : 'No'),
|
||||
},
|
||||
{
|
||||
accessor: 'views',
|
||||
sortable: true,
|
||||
render: (file) => file.views,
|
||||
},
|
||||
{
|
||||
accessor: 'id',
|
||||
hidden: searchField !== 'id' || searchQuery.id.trim() === '',
|
||||
filtering: searchField === 'id' && searchQuery.id.trim() !== '',
|
||||
},
|
||||
...columns,
|
||||
{
|
||||
accessor: 'actions',
|
||||
textAlign: 'right',
|
||||
@@ -503,7 +537,7 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='View file in new tab'>
|
||||
<Link href={`/view/${file.name}`} target='_blank'>
|
||||
<Link to={`/view/${file.name}`} target='_blank'>
|
||||
<ActionIcon color='blue'>
|
||||
<IconExternalLink size='1rem' />
|
||||
</ActionIcon>
|
||||
@@ -521,6 +555,18 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Download file'>
|
||||
<ActionIcon
|
||||
color='gray'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadFile(file);
|
||||
}}
|
||||
>
|
||||
<IconDownload size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Delete file'>
|
||||
<ActionIcon
|
||||
color='red'
|
||||
|
||||
@@ -12,22 +12,19 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Link from 'next/link';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
|
||||
const DashboardFile = dynamic(() => import('@/components/file/DashboardFile'), {
|
||||
loading: () => <Skeleton height={350} animate />,
|
||||
});
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
|
||||
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
|
||||
|
||||
export default function Files({ id }: { id?: string }) {
|
||||
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
||||
const [perpage, setPerpage] = useState<number>(15);
|
||||
const [cachedPages, setCachedPages] = useState<number>(1);
|
||||
const [page, setPage] = useQueryState('page', 1);
|
||||
const [perpage, setPerpage] = useState(15);
|
||||
const [cachedPages, setCachedPages] = useState(1);
|
||||
|
||||
const { data, isLoading } = useApiPagination({
|
||||
page,
|
||||
@@ -60,7 +57,11 @@ export default function Files({ id }: { id?: string }) {
|
||||
{isLoading ? (
|
||||
[...Array(9)].map((_, i) => <Skeleton key={i} height={350} animate />)
|
||||
) : (data?.page?.length ?? 0 > 0) ? (
|
||||
data?.page.map((file) => <DashboardFile key={file.id} file={file} />)
|
||||
data?.page.map((file) => (
|
||||
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
|
||||
<DashboardFile file={file} />
|
||||
</Suspense>
|
||||
))
|
||||
) : (
|
||||
<Paper withBorder p='sm'>
|
||||
<Center>
|
||||
@@ -75,7 +76,7 @@ export default function Files({ id }: { id?: string }) {
|
||||
size='compact-sm'
|
||||
leftSection={<IconFileUpload size='1rem' />}
|
||||
component={Link}
|
||||
href='/dashboard/upload/file'
|
||||
to='/dashboard/upload/file'
|
||||
>
|
||||
Upload a file
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import DashboardFile from '@/components/file/DashboardFile';
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
@@ -8,16 +8,19 @@ import {
|
||||
Pagination,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useApiPagination } from '../files/useApiPagination';
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
|
||||
export default function FavoriteFiles() {
|
||||
const [page, setPage] = useQueryState('fpage', parseAsInteger.withDefault(1));
|
||||
const [page, setPage] = useQueryState('fpage', 1);
|
||||
const { data, isLoading } = useApiPagination({
|
||||
page,
|
||||
favorite: true,
|
||||
@@ -47,7 +50,11 @@ export default function FavoriteFiles() {
|
||||
<LoadingOverlay visible />
|
||||
</Paper>
|
||||
) : (data?.page.length ?? 0 > 0) ? (
|
||||
data?.page.map((file) => <DashboardFile key={file.id} file={file} />)
|
||||
data?.page.map((file) => (
|
||||
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
|
||||
<DashboardFile file={file} />
|
||||
</Suspense>
|
||||
))
|
||||
) : (
|
||||
<Paper withBorder p='sm'>
|
||||
<Center>
|
||||
@@ -61,7 +68,7 @@ export default function FavoriteFiles() {
|
||||
size='compact-sm'
|
||||
leftSection={<IconFileUpload size='1rem' />}
|
||||
component={Link}
|
||||
href='/dashboard/upload/file'
|
||||
to='/dashboard/upload/file'
|
||||
>
|
||||
Upload a file
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import DashboardFile from '@/components/file/DashboardFile';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { Alert, Anchor, Button, CopyButton, Group, Modal, SimpleGrid, Text } from '@mantine/core';
|
||||
import { Alert, Anchor, Button, CopyButton, Group, Modal, SimpleGrid, Skeleton, Text } from '@mantine/core';
|
||||
import { IconShare } from '@tabler/icons-react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
|
||||
export default function ViewFilesModal({
|
||||
folder,
|
||||
@@ -55,7 +57,11 @@ export default function ViewFilesModal({
|
||||
}}
|
||||
pos='relative'
|
||||
>
|
||||
{folder?.files?.map((file) => <DashboardFile file={file} key={file.id} />)}
|
||||
{folder?.files?.map((file) => (
|
||||
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
|
||||
<DashboardFile file={file} key={file.id} />
|
||||
</Suspense>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useClipboard } from '@mantine/hooks';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCheck, IconCopy, IconFolderOff } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
export async function deleteFolder(folder: Folder) {
|
||||
@@ -30,7 +30,7 @@ export function copyFolderUrl(folder: Folder, clipboard: ReturnType<typeof useCl
|
||||
notifications.show({
|
||||
title: 'Copied link',
|
||||
message: (
|
||||
<Anchor component={Link} href={`/folder/${folder.id}`}>
|
||||
<Anchor component={Link} to={`/folder/${folder.id}`}>
|
||||
{`${window.location.protocol}//${window.location.host}/folder/${folder.id}`}
|
||||
</Anchor>
|
||||
),
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ActionIcon, Button, Group, Modal, Stack, Switch, TextInput, Title, Tool
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconFolderPlus, IconPlus } from '@tabler/icons-react';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { useState } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
import FolderGridView from './views/FolderGridView';
|
||||
import FolderTableView from './views/FolderTableView';
|
||||
@@ -15,7 +15,7 @@ import FolderTableView from './views/FolderTableView';
|
||||
export default function DashboardFolders() {
|
||||
const view = useViewStore((state) => state.folders);
|
||||
|
||||
const [open, setOpen] = useQueryState('cfopen', parseAsBoolean.withDefault(false));
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
|
||||
@@ -37,7 +37,9 @@ export default function FolderGridView() {
|
||||
}}
|
||||
pos='relative'
|
||||
>
|
||||
{folders?.map((folder) => <FolderCard key={folder.id} folder={folder} />)}
|
||||
{folders?.map((folder) => (
|
||||
<FolderCard key={folder.id} folder={folder} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Paper withBorder p='sm' my='sm'>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Anchor } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCheck, IconCopy, IconTagOff } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
export async function deleteInvite(warnDeletion: boolean, invite: Invite) {
|
||||
@@ -23,7 +23,7 @@ export function copyInviteUrl(invite: Invite, clipboard: ReturnType<typeof useCl
|
||||
notifications.show({
|
||||
title: 'Copied link',
|
||||
message: (
|
||||
<Anchor component={Link} href={`/invite/${invite.code}`}>
|
||||
<Anchor component={Link} to={`/invite/${invite.code}`}>
|
||||
{`${window.location.protocol}//${window.location.host}/invite/${invite.code}`}
|
||||
</Anchor>
|
||||
),
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import GridTableSwitcher from '@/components/GridTableSwitcher';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Invite } from '@/lib/db/models/invite';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useViewStore } from '@/lib/store/view';
|
||||
import { ActionIcon, Button, Group, Modal, NumberInput, Select, Stack, Title, Tooltip } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconPlus, IconTagOff } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
import InviteGridView from './views/InviteGridView';
|
||||
import InviteTableView from './views/InviteTableView';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { Invite } from '@/lib/db/models/invite';
|
||||
import { mutate } from 'swr';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
|
||||
export default function DashboardInvites() {
|
||||
const view = useViewStore((state) => state.invites);
|
||||
const [open, setOpen] = useQueryState('ciopen', parseAsBoolean.withDefault(false));
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm<{
|
||||
maxUses: number | '';
|
||||
|
||||
@@ -37,7 +37,9 @@ export default function InviteGridView() {
|
||||
}}
|
||||
pos='relative'
|
||||
>
|
||||
{folders?.map((invite) => <InviteCard key={invite.id} invite={invite} />)}
|
||||
{folders?.map((invite) => (
|
||||
<InviteCard key={invite.id} invite={invite} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Paper withBorder p='sm' my='sm'>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ActionIcon, Tooltip } from '@mantine/core';
|
||||
import Link from 'next/link';
|
||||
import styles from './ExternalAuthButton.module.css';
|
||||
|
||||
export default function ExternalAuthButton({
|
||||
@@ -12,7 +11,7 @@ export default function ExternalAuthButton({
|
||||
return (
|
||||
<Tooltip label={`Continue with ${provider}`}>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
component={'a'}
|
||||
href={`/api/auth/oauth/${provider.toLowerCase()}`}
|
||||
color={`${provider.toLowerCase()}.0`}
|
||||
className={styles.button}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { Box, Button, Group, Modal, Paper, SimpleGrid, Text, Title, Tooltip } from '@mantine/core';
|
||||
import { DatePicker } from '@mantine/dates';
|
||||
import { IconCalendarSearch, IconCalendarTime } from '@tabler/icons-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { lazy, useEffect, useState } from 'react';
|
||||
import FilesUrlsCountGraph from './parts/FilesUrlsCountGraph';
|
||||
import { useApiStats } from './useStats';
|
||||
import { StatsCardsSkeleton } from './parts/StatsCards';
|
||||
import { StatsTablesSkeleton } from './parts/StatsTables';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const StatsCards = dynamic(() => import('./parts/StatsCards'));
|
||||
const StatsTables = dynamic(() => import('./parts/StatsTables'));
|
||||
const StorageGraph = dynamic(() => import('./parts/StorageGraph'));
|
||||
const ViewsGraph = dynamic(() => import('./parts/ViewsGraph'));
|
||||
const StorageGraph = lazy(() => import('./parts/StorageGraph'));
|
||||
const ViewsGraph = lazy(() => import('./parts/ViewsGraph'));
|
||||
const StatsCards = lazy(() => import('./parts/StatsCards'));
|
||||
const StatsTables = lazy(() => import('./parts/StatsTables'));
|
||||
|
||||
export default function DashboardMetrics() {
|
||||
const today = dayjs();
|
||||
|
||||
const [dateRange, setDateRange] = useState<[string | null, string | null]>([
|
||||
new Date(Date.now() - 86400000 * 7).toISOString(),
|
||||
new Date().toISOString(),
|
||||
today.subtract(7, 'day').toISOString(),
|
||||
today.toISOString(),
|
||||
]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -40,17 +42,49 @@ export default function DashboardMetrics() {
|
||||
return (
|
||||
<>
|
||||
<Modal title='Change range' opened={open} onClose={() => setOpen(false)} size='auto'>
|
||||
<Paper withBorder>
|
||||
<Paper withBorder style={{ minHeight: 300 }}>
|
||||
<DatePicker
|
||||
type='range'
|
||||
value={dateRange}
|
||||
onChange={handleDateChange}
|
||||
allowSingleDateInRange={false}
|
||||
maxDate={new Date()}
|
||||
presets={[
|
||||
{
|
||||
value: [today.subtract(2, 'day').format('YYYY-MM-DD'), today.format('YYYY-MM-DD')],
|
||||
label: 'Last two days',
|
||||
},
|
||||
{
|
||||
value: [today.subtract(7, 'day').format('YYYY-MM-DD'), today.format('YYYY-MM-DD')],
|
||||
label: 'Last 7 days',
|
||||
},
|
||||
{
|
||||
value: [today.startOf('month').format('YYYY-MM-DD'), today.format('YYYY-MM-DD')],
|
||||
label: 'This month',
|
||||
},
|
||||
{
|
||||
value: [
|
||||
today.subtract(1, 'month').startOf('month').format('YYYY-MM-DD'),
|
||||
today.subtract(1, 'month').endOf('month').format('YYYY-MM-DD'),
|
||||
],
|
||||
label: 'Last month',
|
||||
},
|
||||
{
|
||||
value: [today.startOf('year').format('YYYY-MM-DD'), today.format('YYYY-MM-DD')],
|
||||
label: 'This year',
|
||||
},
|
||||
{
|
||||
value: [
|
||||
today.subtract(1, 'year').startOf('year').format('YYYY-MM-DD'),
|
||||
today.subtract(1, 'year').endOf('year').format('YYYY-MM-DD'),
|
||||
],
|
||||
label: 'Last year',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Group mt='md'>
|
||||
<Group mt='lg'>
|
||||
<Button fullWidth onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
|
||||
@@ -1,57 +1,69 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Group, SimpleGrid, Skeleton, Stack, Title } from '@mantine/core';
|
||||
import { Alert, Anchor, Collapse, Group, SimpleGrid, Skeleton, Stack, Title } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import useSWR from 'swr';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { lazy, Suspense, useMemo } from 'react';
|
||||
|
||||
const Core = lazy(() => import('./parts/Core'));
|
||||
const Chunks = lazy(() => import('./parts/Chunks'));
|
||||
const Discord = lazy(() => import('./parts/Discord'));
|
||||
const Domains = lazy(() => import('./parts/Domains'));
|
||||
const Features = lazy(() => import('./parts/Features'));
|
||||
const Files = lazy(() => import('./parts/Files'));
|
||||
const HttpWebhook = lazy(() => import('./parts/HttpWebhook'));
|
||||
const Invites = lazy(() => import('./parts/Invites'));
|
||||
const Mfa = lazy(() => import('./parts/Mfa'));
|
||||
const Oauth = lazy(() => import('./parts/Oauth'));
|
||||
const PWA = lazy(() => import('./parts/PWA'));
|
||||
const Ratelimit = lazy(() => import('./parts/Ratelimit'));
|
||||
const Tasks = lazy(() => import('./parts/Tasks'));
|
||||
const Urls = lazy(() => import('./parts/Urls'));
|
||||
const Website = lazy(() => import('./parts/Website'));
|
||||
|
||||
function SettingsSkeleton() {
|
||||
return <Skeleton height={280} animate />;
|
||||
return Array(17)
|
||||
.fill(null)
|
||||
.map((_, index) => <Skeleton key={index} height={280} animate />);
|
||||
}
|
||||
|
||||
const ServerSettingsCore = dynamic(() => import('./parts/ServerSettingsCore'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsChunks = dynamic(() => import('./parts/ServerSettingsChunks'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsDiscord = dynamic(() => import('./parts/ServerSettingsDiscord'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsFeatures = dynamic(() => import('./parts/ServerSettingsFeatures'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsFiles = dynamic(() => import('./parts/ServerSettingsFiles'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsHttpWebhook = dynamic(() => import('./parts/ServerSettingsHttpWebhook'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsInvites = dynamic(() => import('./parts/ServerSettingsInvites'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsMfa = dynamic(() => import('./parts/ServerSettingsMfa'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsOauth = dynamic(() => import('./parts/ServerSettingsOauth'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsRatelimit = dynamic(() => import('./parts/ServerSettingsRatelimit'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsTasks = dynamic(() => import('./parts/ServerSettingsTasks'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsUrls = dynamic(() => import('./parts/ServerSettingsUrls'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsWebsite = dynamic(() => import('./parts/ServerSettingsWebsite'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsPWA = dynamic(() => import('./parts/ServerSettingsPWA'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
|
||||
export default function DashboardSettings() {
|
||||
export default function DashboardServerSettings() {
|
||||
const { data, isLoading, error } = useSWR<Response['/api/server/settings']>('/api/server/settings');
|
||||
const [opened, { toggle }] = useDisclosure(false);
|
||||
|
||||
const scrollToSetting = useMemo(() => {
|
||||
return (setting: string) => {
|
||||
console.log('scrolling to setting:', setting);
|
||||
const input = document.querySelector<HTMLInputElement>(`[data-path="${setting}"]`);
|
||||
if (input) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
observer.disconnect();
|
||||
const parent = input.parentElement?.parentElement;
|
||||
if (parent) {
|
||||
parent.style.transition = 'transform 0.35s';
|
||||
parent.style.transform = 'scale(1.2)';
|
||||
setTimeout(() => {
|
||||
parent.style.transform = 'scale(1)';
|
||||
}, 350);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 1.0 },
|
||||
);
|
||||
observer.observe(input);
|
||||
|
||||
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
input.focus();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onTamperedClick = (e: React.MouseEvent<HTMLAnchorElement>, setting: string) => {
|
||||
e.preventDefault();
|
||||
|
||||
scrollToSetting(setting);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -59,36 +71,61 @@ export default function DashboardSettings() {
|
||||
<Title order={1}>Server Settings</Title>
|
||||
</Group>
|
||||
|
||||
{(data?.tampered?.length ?? 0) > 0 && (
|
||||
<Alert color='red' title='Environment Variable Settings' mt='md'>
|
||||
<strong>{data!.tampered.length}</strong> setting{data!.tampered.length > 1 ? 's' : ''} have been set
|
||||
via environment variables, therefore any changes made to them on this page will not take effect
|
||||
unless the environment variable corresponding to the setting is removed. If you prefer using
|
||||
environment variables, you can ignore this message. Click{' '}
|
||||
<Anchor onClick={toggle} size='sm'>
|
||||
here
|
||||
</Anchor>{' '}
|
||||
to {opened ? 'close' : 'view'} the list of overridden settings.
|
||||
<Collapse in={opened} transitionDuration={200}>
|
||||
<ul>
|
||||
{data!.tampered.map((setting) => (
|
||||
<li key={setting}>
|
||||
<Anchor onClick={(e) => onTamperedClick(e, setting)}>{setting}</Anchor>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Collapse>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
{error ? (
|
||||
<div>Error loading server settings</div>
|
||||
) : (
|
||||
<>
|
||||
<ServerSettingsCore swr={{ data, isLoading }} />
|
||||
<ServerSettingsChunks swr={{ data, isLoading }} />
|
||||
<ServerSettingsTasks swr={{ data, isLoading }} />
|
||||
<ServerSettingsMfa swr={{ data, isLoading }} />
|
||||
<Suspense fallback={<SettingsSkeleton />}>
|
||||
<Core swr={{ data, isLoading }} />
|
||||
<Chunks swr={{ data, isLoading }} />
|
||||
<Tasks swr={{ data, isLoading }} />
|
||||
<Mfa swr={{ data, isLoading }} />
|
||||
|
||||
<ServerSettingsFeatures swr={{ data, isLoading }} />
|
||||
<ServerSettingsFiles swr={{ data, isLoading }} />
|
||||
<Features swr={{ data, isLoading }} />
|
||||
<Files swr={{ data, isLoading }} />
|
||||
<Stack>
|
||||
<ServerSettingsUrls swr={{ data, isLoading }} />
|
||||
<ServerSettingsInvites swr={{ data, isLoading }} />
|
||||
<Urls swr={{ data, isLoading }} />
|
||||
<Invites swr={{ data, isLoading }} />
|
||||
</Stack>
|
||||
|
||||
<ServerSettingsRatelimit swr={{ data, isLoading }} />
|
||||
<ServerSettingsWebsite swr={{ data, isLoading }} />
|
||||
<ServerSettingsOauth swr={{ data, isLoading }} />
|
||||
<Ratelimit swr={{ data, isLoading }} />
|
||||
<Stack>
|
||||
<Website swr={{ data, isLoading }} />
|
||||
<PWA swr={{ data, isLoading }} />
|
||||
</Stack>
|
||||
<Oauth swr={{ data, isLoading }} />
|
||||
|
||||
<ServerSettingsPWA swr={{ data, isLoading }} />
|
||||
<HttpWebhook swr={{ data, isLoading }} />
|
||||
|
||||
<ServerSettingsHttpWebhook swr={{ data, isLoading }} />
|
||||
</>
|
||||
<Domains swr={{ data, isLoading }} />
|
||||
</Suspense>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
<Stack mt='md' gap='md'>
|
||||
{error ? null : <ServerSettingsDiscord swr={{ data, isLoading }} />}
|
||||
{error ? null : <Discord swr={{ data, isLoading }} />}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,33 +2,40 @@ import { Response } from '@/lib/api/response';
|
||||
import { Button, LoadingOverlay, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
export default function ServerSettingsChunks({
|
||||
export default function Chunks({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
chunksEnabled: true,
|
||||
chunksMax: '95mb',
|
||||
chunksSize: '25mb',
|
||||
},
|
||||
enhanceGetInputProps: (payload: any): object => ({
|
||||
disabled:
|
||||
data?.tampered?.includes(payload.field) ||
|
||||
(payload.field !== 'chunksEnabled' && !form.values.chunksEnabled) ||
|
||||
false,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = settingsOnSubmit(router, form);
|
||||
const onSubmit = settingsOnSubmit(navigate, form);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
form.setValues({
|
||||
chunksEnabled: data?.chunksEnabled ?? true,
|
||||
chunksMax: data!.chunksMax ?? '',
|
||||
chunksSize: data!.chunksSize ?? '',
|
||||
chunksEnabled: data.settings.chunksEnabled ?? true,
|
||||
chunksMax: data.settings.chunksMax ?? '',
|
||||
chunksSize: data.settings.chunksSize ?? '',
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -2,26 +2,32 @@ import { Response } from '@/lib/api/response';
|
||||
import { Button, LoadingOverlay, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
export default function ServerSettingsCore({
|
||||
export default function Core({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm<{
|
||||
coreReturnHttpsUrls: boolean;
|
||||
coreDefaultDomain: string | null | undefined;
|
||||
coreTempDirectory: string;
|
||||
coreTrustProxy: boolean;
|
||||
}>({
|
||||
initialValues: {
|
||||
coreReturnHttpsUrls: false,
|
||||
coreDefaultDomain: '',
|
||||
coreTempDirectory: '/tmp/zipline',
|
||||
coreTrustProxy: false,
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
@@ -31,14 +37,17 @@ export default function ServerSettingsCore({
|
||||
values.coreDefaultDomain = values.coreDefaultDomain.trim();
|
||||
}
|
||||
|
||||
return settingsOnSubmit(router, form)(values);
|
||||
return settingsOnSubmit(navigate, form)(values);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
form.setValues({
|
||||
coreReturnHttpsUrls: data?.coreReturnHttpsUrls ?? false,
|
||||
coreDefaultDomain: data?.coreDefaultDomain ?? '',
|
||||
coreTempDirectory: data?.coreTempDirectory ?? '/tmp/zipline',
|
||||
coreReturnHttpsUrls: data.settings.coreReturnHttpsUrls ?? false,
|
||||
coreDefaultDomain: data.settings.coreDefaultDomain ?? '',
|
||||
coreTempDirectory: data.settings.coreTempDirectory ?? '/tmp/zipline',
|
||||
coreTrustProxy: data.settings.coreTrustProxy ?? false,
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -49,14 +58,20 @@ export default function ServerSettingsCore({
|
||||
<Title order={2}>Core</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Switch
|
||||
mt='md'
|
||||
label='Return HTTPS URLs'
|
||||
description='Return URLs with HTTPS protocol.'
|
||||
{...form.getInputProps('coreReturnHttpsUrls', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Switch
|
||||
mt='md'
|
||||
label='Return HTTPS URLs'
|
||||
description='Return URLs with HTTPS protocol.'
|
||||
{...form.getInputProps('coreReturnHttpsUrls', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label='Trust Proxies'
|
||||
description='Trust the X-Forwarded-* headers set by proxies. Only enable this if you are behind a trusted proxy (nginx, caddy, etc.). Requires a server restart.'
|
||||
{...form.getInputProps('coreTrustProxy', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Default Domain'
|
||||
description='The domain to use when generating URLs. This value should not include the protocol.'
|
||||
@@ -13,18 +13,18 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
type DiscordEmbed = Record<string, any>;
|
||||
|
||||
export default function ServerSettingsDiscord({
|
||||
export default function Discord({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const formMain = useForm({
|
||||
initialValues: {
|
||||
@@ -44,7 +44,7 @@ export default function ServerSettingsDiscord({
|
||||
sendValues.discordAvatarUrl =
|
||||
values.discordAvatarUrl?.trim() === '' ? null : values.discordAvatarUrl?.trim();
|
||||
|
||||
return settingsOnSubmit(router, formMain)(sendValues);
|
||||
return settingsOnSubmit(navigate, formMain)(sendValues);
|
||||
};
|
||||
|
||||
const formOnUpload = useForm({
|
||||
@@ -65,6 +65,9 @@ export default function ServerSettingsDiscord({
|
||||
discordOnUploadEmbedTimestamp: false,
|
||||
discordOnUploadEmbedUrl: false,
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
}),
|
||||
});
|
||||
|
||||
const formOnShorten = useForm({
|
||||
@@ -117,48 +120,52 @@ export default function ServerSettingsDiscord({
|
||||
};
|
||||
}
|
||||
|
||||
return settingsOnSubmit(router, type === 'upload' ? formOnUpload : formOnShorten)(sendValues);
|
||||
return settingsOnSubmit(navigate, type === 'upload' ? formOnUpload : formOnShorten)(sendValues);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
formMain.setValues({
|
||||
discordWebhookUrl: data?.discordWebhookUrl ?? '',
|
||||
discordUsername: data?.discordUsername ?? '',
|
||||
discordAvatarUrl: data?.discordAvatarUrl ?? '',
|
||||
discordWebhookUrl: data.settings.discordWebhookUrl ?? '',
|
||||
discordUsername: data.settings.discordUsername ?? '',
|
||||
discordAvatarUrl: data.settings.discordAvatarUrl ?? '',
|
||||
});
|
||||
|
||||
formOnUpload.setValues({
|
||||
discordOnUploadWebhookUrl: data?.discordOnUploadWebhookUrl ?? '',
|
||||
discordOnUploadUsername: data?.discordOnUploadUsername ?? '',
|
||||
discordOnUploadAvatarUrl: data?.discordOnUploadAvatarUrl ?? '',
|
||||
discordOnUploadWebhookUrl: data.settings.discordOnUploadWebhookUrl ?? '',
|
||||
discordOnUploadUsername: data.settings.discordOnUploadUsername ?? '',
|
||||
discordOnUploadAvatarUrl: data.settings.discordOnUploadAvatarUrl ?? '',
|
||||
|
||||
discordOnUploadContent: data?.discordOnUploadContent ?? '',
|
||||
discordOnUploadEmbed: data?.discordOnUploadEmbed ? true : false,
|
||||
discordOnUploadEmbedTitle: (data?.discordOnUploadEmbed as DiscordEmbed)?.title ?? '',
|
||||
discordOnUploadEmbedDescription: (data?.discordOnUploadEmbed as DiscordEmbed)?.description ?? '',
|
||||
discordOnUploadEmbedFooter: (data?.discordOnUploadEmbed as DiscordEmbed)?.footer ?? '',
|
||||
discordOnUploadEmbedColor: (data?.discordOnUploadEmbed as DiscordEmbed)?.color ?? '',
|
||||
discordOnUploadEmbedThumbnail: (data?.discordOnUploadEmbed as DiscordEmbed)?.thumbnail ?? false,
|
||||
discordOnUploadEmbedImageOrVideo: (data?.discordOnUploadEmbed as DiscordEmbed)?.imageOrVideo ?? false,
|
||||
discordOnUploadEmbedTimestamp: (data?.discordOnUploadEmbed as DiscordEmbed)?.timestamp ?? false,
|
||||
discordOnUploadEmbedUrl: (data?.discordOnUploadEmbed as DiscordEmbed)?.url ?? false,
|
||||
discordOnUploadContent: data.settings.discordOnUploadContent ?? '',
|
||||
discordOnUploadEmbed: data.settings.discordOnUploadEmbed ? true : false,
|
||||
discordOnUploadEmbedTitle: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.title ?? '',
|
||||
discordOnUploadEmbedDescription:
|
||||
(data.settings.discordOnUploadEmbed as DiscordEmbed)?.description ?? '',
|
||||
discordOnUploadEmbedFooter: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.footer ?? '',
|
||||
discordOnUploadEmbedColor: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.color ?? '',
|
||||
discordOnUploadEmbedThumbnail: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.thumbnail ?? false,
|
||||
discordOnUploadEmbedImageOrVideo:
|
||||
(data.settings.discordOnUploadEmbed as DiscordEmbed)?.imageOrVideo ?? false,
|
||||
discordOnUploadEmbedTimestamp: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.timestamp ?? false,
|
||||
discordOnUploadEmbedUrl: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.url ?? false,
|
||||
});
|
||||
|
||||
formOnShorten.setValues({
|
||||
discordOnShortenWebhookUrl: data?.discordOnShortenWebhookUrl ?? '',
|
||||
discordOnShortenUsername: data?.discordOnShortenUsername ?? '',
|
||||
discordOnShortenAvatarUrl: data?.discordOnShortenAvatarUrl ?? '',
|
||||
discordOnShortenWebhookUrl: data.settings.discordOnShortenWebhookUrl ?? '',
|
||||
discordOnShortenUsername: data.settings.discordOnShortenUsername ?? '',
|
||||
discordOnShortenAvatarUrl: data.settings.discordOnShortenAvatarUrl ?? '',
|
||||
|
||||
discordOnShortenContent: data?.discordOnShortenContent ?? '',
|
||||
discordOnShortenEmbed: data?.discordOnShortenEmbed ? true : false,
|
||||
discordOnShortenEmbedTitle: (data?.discordOnShortenEmbed as DiscordEmbed)?.title ?? '',
|
||||
discordOnShortenEmbedDescription: (data?.discordOnShortenEmbed as DiscordEmbed)?.description ?? '',
|
||||
discordOnShortenEmbedFooter: (data?.discordOnShortenEmbed as DiscordEmbed)?.footer ?? '',
|
||||
discordOnShortenEmbedColor: (data?.discordOnShortenEmbed as DiscordEmbed)?.color ?? '',
|
||||
discordOnShortenEmbedTimestamp: (data?.discordOnShortenEmbed as DiscordEmbed)?.timestamp ?? false,
|
||||
discordOnShortenEmbedUrl: (data?.discordOnShortenEmbed as DiscordEmbed)?.url ?? false,
|
||||
discordOnShortenContent: data.settings.discordOnShortenContent ?? '',
|
||||
discordOnShortenEmbed: data.settings.discordOnShortenEmbed ? true : false,
|
||||
discordOnShortenEmbedTitle: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.title ?? '',
|
||||
discordOnShortenEmbedDescription:
|
||||
(data.settings.discordOnShortenEmbed as DiscordEmbed)?.description ?? '',
|
||||
discordOnShortenEmbedFooter: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.footer ?? '',
|
||||
discordOnShortenEmbedColor: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.color ?? '',
|
||||
discordOnShortenEmbedTimestamp:
|
||||
(data.settings.discordOnShortenEmbed as DiscordEmbed)?.timestamp ?? false,
|
||||
discordOnShortenEmbedUrl: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.url ?? false,
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
125
src/components/pages/serverSettings/parts/Domains.tsx
Normal file
125
src/components/pages/serverSettings/parts/Domains.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Button, Group, LoadingOverlay, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
const DOMAIN_REGEX =
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9-_]{0,61}[a-zA-Z0-9]{0,1}\.([a-zA-Z]{1,6}|[a-zA-Z0-9-]{1,30}\.[a-zA-Z]{2,30})$/gim;
|
||||
|
||||
export default function Domains({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [domains, setDomains] = useState<string[]>([]);
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
newDomain: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = settingsOnSubmit(navigate, form);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const domainsData = Array.isArray(data.settings.domains)
|
||||
? data.settings.domains.map((d) => String(d))
|
||||
: [];
|
||||
setDomains(domainsData);
|
||||
}, [data]);
|
||||
|
||||
const addDomain = () => {
|
||||
const { newDomain } = form.values;
|
||||
if (!newDomain) return;
|
||||
|
||||
if (!DOMAIN_REGEX.test(newDomain)) {
|
||||
return form.setFieldError('newDomain', 'Invalid Domain');
|
||||
}
|
||||
|
||||
const updatedDomains = [...domains, newDomain.trim()];
|
||||
setDomains(updatedDomains);
|
||||
form.setValues({ newDomain: '' });
|
||||
onSubmit({ domains: updatedDomains });
|
||||
};
|
||||
|
||||
const removeDomain = (index: number) => {
|
||||
const updatedDomains = domains.filter((_, i) => i !== index);
|
||||
setDomains(updatedDomains);
|
||||
onSubmit({ domains: updatedDomains });
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>Domains</Title>
|
||||
|
||||
<Group mt='md' align='flex-end'>
|
||||
<TextInput
|
||||
label='Domain'
|
||||
description='Enter a domain name (e.g. example.com)'
|
||||
placeholder='example.com'
|
||||
{...form.getInputProps('newDomain')}
|
||||
/>
|
||||
<Button onClick={addDomain} leftSection={<IconPlus size='1rem' />}>
|
||||
Add Domain
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid mt='md' cols={{ base: 1, sm: 2, md: 3 }} spacing='md' verticalSpacing='md'>
|
||||
{domains.map((domain, index) => (
|
||||
<Paper
|
||||
key={index}
|
||||
withBorder
|
||||
p='md'
|
||||
radius='md'
|
||||
shadow='xs'
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.03)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
minHeight: 64,
|
||||
}}
|
||||
>
|
||||
<Group justify='space-between' align='center' wrap='nowrap'>
|
||||
<div
|
||||
style={{
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 500,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{domain}
|
||||
</div>
|
||||
<Button
|
||||
variant='subtle'
|
||||
color='red'
|
||||
size='xs'
|
||||
onClick={() => removeDomain(index)}
|
||||
px={8}
|
||||
style={{
|
||||
aspectRatio: '1/1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<IconTrash size='1rem' />
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
LoadingOverlay,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Switch,
|
||||
TextInput,
|
||||
@@ -12,16 +13,17 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
export default function ServerSettingsFeatures({
|
||||
export default function Features({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
featuresImageCompression: true,
|
||||
@@ -32,31 +34,38 @@ export default function ServerSettingsFeatures({
|
||||
featuresDeleteOnMaxViews: true,
|
||||
featuresThumbnailsEnabled: true,
|
||||
featuresThumbnailsNumberThreads: 4,
|
||||
featuresThumbnailsFormat: 'jpg',
|
||||
featuresMetricsEnabled: true,
|
||||
featuresMetricsAdminOnly: false,
|
||||
featuresMetricsShowUserSpecific: true,
|
||||
featuresVersionChecking: true,
|
||||
featuresVersionAPI: 'https://zipline-version.diced.sh/',
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = settingsOnSubmit(router, form);
|
||||
const onSubmit = settingsOnSubmit(navigate, form);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
form.setValues({
|
||||
featuresImageCompression: data?.featuresImageCompression ?? true,
|
||||
featuresRobotsTxt: data?.featuresRobotsTxt ?? true,
|
||||
featuresHealthcheck: data?.featuresHealthcheck ?? true,
|
||||
featuresUserRegistration: data?.featuresUserRegistration ?? false,
|
||||
featuresOauthRegistration: data?.featuresOauthRegistration ?? true,
|
||||
featuresDeleteOnMaxViews: data?.featuresDeleteOnMaxViews ?? true,
|
||||
featuresThumbnailsEnabled: data?.featuresThumbnailsEnabled ?? true,
|
||||
featuresThumbnailsNumberThreads: data?.featuresThumbnailsNumberThreads ?? 4,
|
||||
featuresMetricsEnabled: data?.featuresMetricsEnabled ?? true,
|
||||
featuresMetricsAdminOnly: data?.featuresMetricsAdminOnly ?? false,
|
||||
featuresMetricsShowUserSpecific: data?.featuresMetricsShowUserSpecific ?? true,
|
||||
featuresVersionChecking: data?.featuresVersionChecking ?? true,
|
||||
featuresVersionAPI: data?.featuresVersionAPI ?? 'https://zipline-version.diced.sh/',
|
||||
featuresImageCompression: data.settings.featuresImageCompression ?? true,
|
||||
featuresRobotsTxt: data.settings.featuresRobotsTxt ?? true,
|
||||
featuresHealthcheck: data.settings.featuresHealthcheck ?? true,
|
||||
featuresUserRegistration: data.settings.featuresUserRegistration ?? false,
|
||||
featuresOauthRegistration: data.settings.featuresOauthRegistration ?? true,
|
||||
featuresDeleteOnMaxViews: data.settings.featuresDeleteOnMaxViews ?? true,
|
||||
featuresThumbnailsEnabled: data.settings.featuresThumbnailsEnabled ?? true,
|
||||
featuresThumbnailsNumberThreads: data.settings.featuresThumbnailsNumberThreads ?? 4,
|
||||
featuresThumbnailsFormat: data.settings.featuresThumbnailsFormat ?? 'jpg',
|
||||
featuresMetricsEnabled: data.settings.featuresMetricsEnabled ?? true,
|
||||
featuresMetricsAdminOnly: data.settings.featuresMetricsAdminOnly ?? false,
|
||||
featuresMetricsShowUserSpecific: data.settings.featuresMetricsShowUserSpecific ?? true,
|
||||
featuresVersionChecking: data.settings.featuresVersionChecking ?? true,
|
||||
featuresVersionAPI: data.settings.featuresVersionAPI ?? 'https://zipline-version.diced.sh/',
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -76,7 +85,7 @@ export default function ServerSettingsFeatures({
|
||||
|
||||
<Switch
|
||||
label='/robots.txt'
|
||||
description='Enables a robots.txt file for search engine optimization. Requires a server restart.'
|
||||
description='Enables a /robots.txt to stop search crawlers. Requires a server restart.'
|
||||
{...form.getInputProps('featuresRobotsTxt', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
@@ -137,6 +146,19 @@ export default function ServerSettingsFeatures({
|
||||
{...form.getInputProps('featuresThumbnailsNumberThreads')}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label='Thumbnails Format'
|
||||
description='The output format for thumbnails. Requires a server restart.'
|
||||
data={[
|
||||
{ value: 'jpg', label: '.jpg' },
|
||||
{ value: 'png', label: '.png' },
|
||||
{ value: 'webp', label: '.webp' },
|
||||
]}
|
||||
{...form.getInputProps('featuresThumbnailsFormat')}
|
||||
/>
|
||||
|
||||
<div />
|
||||
|
||||
<Switch
|
||||
label='Version Checking'
|
||||
description='Enable version checking for the server. This will check for updates and display the status on the sidebar to all users.'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user