Compare commits

..

133 Commits

Author SHA1 Message Date
diced
ec21dbce36 fix: build stage order 2025-08-14 12:30:06 -07:00
diced
152876657d fix: regex when adding domains 2025-08-13 16:30:14 -07:00
diced
01b5fc8930 fix: linting error 2025-08-13 00:30:39 -07:00
diced
ca149f920b feat: small ui improvements 2025-08-12 22:15:04 -07:00
diced
9c473ae30d feat: small ui fixes and improvements 2025-08-12 22:14:43 -07:00
dicedtomato
4377c396d6 Merge branch 'trunk' into no-next 2025-08-09 22:56:45 -07:00
diced
71dbbb584a feat(v4.2.3): version 2025-08-09 22:46:30 -07:00
Snipcola
f03bd74865 fix: wrong env vars (#858)
* capitalize `random words separator` environment variable

* change `RATELIMIT_WINDOW` environment variable type to number
2025-08-09 22:42:01 -07:00
dicedtomato
17a6abb882 Merge branch 'trunk' into no-next 2025-08-08 22:49:12 -07:00
diced
f059dcca35 fix: once and for all fix #854 2025-08-08 22:47:51 -07:00
diced
eccbdd9792 fix: removed unused vars 2025-08-08 15:14:51 -07:00
dicedtomato
74c8208b6a Merge branch 'trunk' into no-next 2025-08-08 15:11:53 -07:00
diced
8cfd3a1c4b feat: dynamic import optimizations + titled pages 2025-08-08 15:11:33 -07:00
diced
531ba13daf fix: no longer use rename since it's weird 2025-08-08 15:02:38 -07:00
dicedtomato
42e0438b00 Merge branch 'trunk' into no-next 2025-08-08 11:13:00 -07:00
diced
03fa98bed6 fix: remove node@20 from actions 2025-08-07 20:03:54 -07:00
diced
cd8b892a90 feat(v4.2.2): version 2025-08-07 19:48:52 -07:00
diced
7997143024 fix: generate prisma before types 2025-08-07 19:47:39 -07:00
dicedtomato
2f211722d8 Merge branch 'trunk' into no-next 2025-08-07 19:41:36 -07:00
diced
3575981984 fix: exdev error workaround #856 2025-08-07 19:31:56 -07:00
dicedtomato
81c880b1ca Merge commit from fork 2025-08-07 19:29:28 -07:00
diced
09e445b9ae fix: docker + exdev error 2025-08-07 15:26:47 -07:00
diced
2e70cf0add fix: ignores + cache action 2025-08-07 14:55:09 -07:00
diced
896e44a10e fix: caching on pnpm action 2025-08-07 13:03:45 -07:00
diced
bc52bc887c fix: build process 2025-08-07 13:01:25 -07:00
diced
af3f23a241 feat: all functionality added + client/ -> src/client/ 2025-08-06 21:52:24 -07:00
diced
e5a01c5034 feat: working ssr + dev + prod env 2025-08-06 13:20:10 -07:00
diced
35a56a72c4 feat: start removing next.js 2025-08-05 01:00:33 -07:00
diced
9b8e57bda0 fix: do not add new sessions on session save (#855) 2025-08-04 11:44:06 -07:00
diced
4a8f90a901 fix: #855 session override bug 2025-08-03 16:24:00 -07:00
diced
6acdc72776 fix: multiple db connections on offloaded threads 2025-08-02 16:53:53 -07:00
diced
f78c873aae fix: revert zod 2025-08-02 16:52:14 -07:00
diced
0f82bf8d90 fix: formatting errors 2025-08-02 16:52:03 -07:00
diced
82a7f1d0bf feat(prisma): use non-rust engines 2025-08-02 16:36:08 -07:00
diced
2fd1007e4b chore: lint + upgrade packages 2025-08-02 15:40:09 -07:00
diced
c360235fa8 fix: better thumbnail logic 2025-08-02 15:29:27 -07:00
diced
a4404f1ae8 fix: refactor routes to be separated 2025-08-02 11:25:16 -07:00
diced
56d1492377 feat: ability to rename files 2025-08-01 16:43:20 -07:00
diced
fa9bf185d5 fix: improve logic in uploading + partial 2025-08-01 12:31:07 -07:00
diced
eca6a0c5fd feat(unstable): implement new uploading logic 2025-07-31 23:23:31 -07:00
diced
f58ed2f368 fix: add minio to flake 2025-07-31 23:22:06 -07:00
diced
64c39dab76 fix: update nix flake to use devenv 2025-07-31 20:22:10 -07:00
diced
ac08f4f797 feat(v4.2.1): version 2025-07-28 12:21:26 -07:00
diced
91a2c05d3b feat: nix dev shell 2025-07-27 12:34:25 -07:00
diced
3ccc108d43 fix: search by id color 2025-07-19 14:32:34 -07:00
diced
aaaf0cf5aa fix: prolly fix #843 2025-07-19 14:27:40 -07:00
diced
db7cf70bca fix: favorite transactional 2025-07-11 11:47:58 -07:00
diced
8b59e1dc53 fix: properly handle custom components 2025-07-08 19:34:59 -07:00
diced
da066db07e fix: discord oauth #833 2025-07-04 14:19:46 -07:00
diced
b566d13c8d fix: random visual bugs + enhancements 2025-07-02 20:41:37 -07:00
diced
6a76c5243f fix: typo separator 2025-07-02 14:12:35 -07:00
curet
38a90787d0 feat: predefined domains (#822)
* feat(domains): add domains to server settings

* fix(domains): fix linting errors

* fix(domains): remove unused imports

* fix(urls): fix typo

* feat(domains): remove expiration date from domains

* feat(domains): changed domains from JSONB to TEXT[]

* fix(domains): linter errors

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2025-07-02 10:52:33 -07:00
diced
4652ada85e feat(v4.2.0): version 2025-07-01 17:43:12 -07:00
diced
5f96c762e0 fix: lint errors 2025-07-01 17:30:49 -07:00
diced
651f32e7ba fix: remove split user/pass error 2025-07-01 17:27:32 -07:00
diced
dcbd9e40f0 fix: use absolute path for mac flameshot 2025-07-01 17:22:19 -07:00
diced
3486e9880e feat: midnight pink theme 2025-07-01 17:15:41 -07:00
diced
b058c15f26 fix: up cookie age 2 weeks 2025-07-01 16:58:35 -07:00
diced
96f60edaee fix: try to fix insane db connections #778 2025-07-01 16:55:57 -07:00
diced
d7f3e1503f fix: broken link partial file #816 2025-07-01 15:53:20 -07:00
diced
dfc8fca3e0 fix: default expiration #821 2025-07-01 15:33:29 -07:00
lajczi
28f7d3f618 chore: update ESLint config (#826)
* chore: update ESLint config

* chore: update file permissions

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2025-07-01 11:38:47 -07:00
curet
5c0830c6da fix: long code blocks (#823) (#810) 2025-07-01 10:58:25 -07:00
diced
ef33fcbe1d fix: lint error 2025-06-11 20:23:25 -07:00
diced
4b1ca07510 feat: better cache for versions 2025-06-11 20:21:52 -07:00
diced
438b9b5a67 feat: show alert when there are overridden settings 2025-06-08 12:02:51 -07:00
diced
ed1273efba feat: convert db settings to env vars cli 2025-06-08 11:52:32 -07:00
diced
e8518f92c7 fix: remove 2025-06-07 11:36:51 -07:00
diced
fbf9e10e56 feat: allow/denylist discord oauth 2025-06-07 11:36:23 -07:00
diced
a1ee1178ae feat: allow env vars that override database set settings 2025-06-07 11:17:43 -07:00
diced
e5eaaca5a0 feat: discord oauth whitelist 2025-06-06 20:33:41 -07:00
diced
6e9dea989e fix: use cmd icon on mac 2025-06-06 15:15:11 -07:00
diced
5bc9b6ef0a feat: add download button to file table view 2025-06-06 15:10:13 -07:00
diced
6362d06253 feat: new gps remover 2025-06-06 15:06:21 -07:00
diced
81866b4b50 feat(v4.1.2): version hotfix 2025-06-06 10:40:57 -07:00
diced
4b3878d553 feat: switch metadata remover 2025-06-06 10:40:38 -07:00
diced
d0a613ab8e feat(v4.1.1): version 2025-06-05 22:52:44 -07:00
diced
1bff0564e7 feat: ratelimits for a bunch of routes (small) 2025-06-05 22:52:23 -07:00
diced
df449b1bcb fix: passkeys route, deleting works now 2025-06-05 15:08:11 -07:00
diced
bd057944ce chore: update dependencies 2025-06-05 14:53:03 -07:00
diced
856fa00d1d fix: linting errors 2025-06-04 23:54:58 -07:00
diced
1703cee75a fix: #817 2025-06-04 23:53:04 -07:00
diced
0a970da241 fix: implement workaround for video-audio contains thumbnail 2025-06-01 12:14:42 -07:00
diced
04b0a18b85 fix: remove ignore tls verify 2025-06-01 11:33:40 -07:00
diced
e7de1c9762 fix: show error when s3 fails 2025-06-01 11:03:12 -07:00
Josh
2df9098586 feat: add service_healthy + healthcheck to docker-compose (#811)
* Update docker-compose.yml

Since you use healthcheck anyway, you get to verify that postgres is healthy

* Update README.md

Update the README with 'depends_on: postgresql:  condition: service_healthy'

* Add zipline healthcheck to docker-compose.yml

* Add docker-compose healthcheck for zipline to README.md

* Update docker-compose.yml

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2025-05-31 15:44:14 -07:00
diced
e8380cc261 fix: allow unauthorized certs s3 2025-05-26 12:26:09 -07:00
diced
71a1ed9072 fix: stop scroll when zoomed in 2025-05-26 12:25:25 -07:00
diced
6b0bbad8d4 feat(v4.1.0): version 2025-05-20 20:58:51 -07:00
diced
8f12621315 feat: new view file showing options 2025-05-20 20:55:52 -07:00
diced
e5ee076e08 fix: better image width/height handling 2025-05-20 19:50:17 -07:00
diced
8382a1b55d fix: remove some paths from safe config 2025-05-18 22:03:44 -07:00
diced
a35d8b87ee feat: more version checking options 2025-05-15 21:09:44 -07:00
diced
f70eea97b0 fix: better DEBUG var handling 2025-05-15 15:09:19 -07:00
diced
7ab5c4e180 fix: add more debug logging when failing oauth 2025-05-13 15:58:20 -07:00
lajczi
486165625d ci: node.js 23 -> 24 (#809) 2025-05-13 14:47:14 -07:00
diced
08eb2df26c fix: typings for version api 2025-05-08 21:29:51 -07:00
diced
4a5d01c663 feat: version checking 2025-05-08 21:20:47 -07:00
diced
485f106a65 fix: remove avatar fetching every 30s 2025-05-08 17:15:33 -07:00
diced
3d3f519403 feat: s3 subdirectory 2025-05-08 15:13:31 -07:00
diced
617f42d3bf fix: remove prisma schema load message 2025-05-08 10:59:18 -07:00
diced
25a2a54d8a fix: unused imports in s3 2025-05-07 20:04:14 -07:00
diced
35c37c235f fix: change access test logic 2025-05-07 19:56:02 -07:00
diced
594dfa6ef9 fix: import issue 2025-05-06 01:19:08 -07:00
diced
5ab36a08b2 feat: expose git sha in versioning api 2025-05-06 01:14:13 -07:00
diced
90aef3dce1 feat: update mantine, prisma, etc. 2025-05-06 00:17:53 -07:00
diced
8b9303ed80 fix: pnpm 10.10 stuff 2025-05-01 17:49:55 -07:00
diced
ee9639ac65 fix: kys 2025-04-29 12:37:34 -07:00
diced
055bee6286 fix: finally fix ts dumb ahh error 2025-04-29 12:35:35 -07:00
diced
c3bc598016 fix: remove unused import 2025-04-29 12:33:39 -07:00
diced
c0261285af fix: don't overwrite sessions webauthn (#792) 2025-04-29 12:24:51 -07:00
diced
0538b792ac feat: better partial upload checking 2025-04-29 12:14:43 -07:00
rlko
567a855ba1 fix: shell script uploader/shorten (#786)
* fix filenames with spaces + fix mime type uploads

* Add proper MIME type for uploads (especially videos)
  in shell script

* Few fixes for shell script uploader

* encapsulating all headers
* tr not needed (anymore?)
* removing `echo` as it is not returning anything
  because it is already printing on stdout
* parsing correct key for url uploader

* `echo` string seems like not needed anymore

also passed prettier

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2025-04-29 11:49:18 -07:00
bigbenster702
2e59f5bd7f fix: discord auto continue on oauth screen (#795) 2025-04-29 11:29:00 -07:00
diced
ef0580655d fix: showing upload button on disabled upload folder (#776) 2025-04-16 21:47:17 -07:00
diced
8ece705eb5 fix: add mimetype to s3 uploads (#785) 2025-04-16 21:44:27 -07:00
diced
485fa62ed9 fix: oauth main api route 2025-04-09 23:04:10 -07:00
diced
b4819cd038 fix: import-dir script will work now 2025-04-08 19:47:42 -07:00
diced
cb2f2daf60 fix: formatting errors 2025-04-05 00:03:43 -07:00
diced
c2848f19c1 fix: malformed s3 multipart uploads (#771) 2025-04-04 19:47:01 -07:00
diced
55684528b8 feat: expose theme on view routes 2025-04-03 12:39:05 -07:00
diced
9611e6d5a5 feat: overhaul qs system 2025-04-03 12:32:27 -07:00
diced
e8207addba feat(v4.0.2): version 2025-03-31 21:32:42 -07:00
diced
d6e52e6dce fix: better s3 error handling (#766) 2025-03-31 21:24:27 -07:00
diced
d3250fdc45 fix: remove filtering empty lines in syntax highlighter 2025-03-31 21:20:21 -07:00
diced
c44572920b fix: syntax highlighting (#697) 2025-03-31 21:17:42 -07:00
diced
20a7f134ad fix: redirect /view on text files 2025-03-30 21:36:29 -07:00
diced
05d3e99cbb fix: up the body limit for importing v3 2025-03-28 16:29:38 -07:00
diced
369a527446 fix: make .stats optional for imports 2025-03-28 15:46:23 -07:00
diced
92b7024111 fix: maybe fix s3 max sockets issue (#752) + update next.js 2025-03-28 15:44:44 -07:00
curet
e1256db661 feat: files per page selector on gallery view (#757)
* feat(files): improved pagination with files per page selector

* style(files): formated imports

* style(files): formated imports

* reverted commit

* feat(files): improved pagination with files per page selector

* Update src/components/pages/files/views/Files.tsx

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2025-03-28 15:24:26 -07:00
dicedtomato
b767a0082e Merge commit from fork
* fix: math.random not random

* fix: ignore require() warnings
2025-03-19 14:42:13 -07:00
curet
c6e536a803 feat: midnight orange/blue themes, fix #754 #751 (#753)
* feat(theme): add midnight orange theme

* feat(files): Added more files to one page & filter by views

* feat(ThemeProvider): Added bigger radius for moderner look
fix(auth): When title to long it fits now better
fix(api): Fixed user favorties not showing right

* fix(auth)

* feat(theme): add midnight blue theme
change(file): updated it 15 files per page

* changed(theme): midnight_orange: add missing colors

* little improvement on the theme files
2025-03-18 14:08:54 -07:00
293 changed files with 11730 additions and 8815 deletions

View File

@@ -1,7 +1,7 @@
.github
.next
build
node_modules
uploads*
.env
.eslintcache
.eslintcache
src/prisma

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake . --no-pure-eval

View File

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

View File

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

View File

@@ -46,6 +46,8 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
build-args: |
ZIPLINE_GIT_SHA=${{ steps.sha.outputs.short_sha }}
tags: |
ghcr.io/diced/zipline:${{ steps.version.outputs.zipline_version }}-${{ matrix.arch }}
ghcr.io/diced/zipline:${{ steps.version.outputs.zipline_version }}-${{ steps.sha.outputs.short_sha }}-${{ matrix.arch }}

View File

@@ -40,6 +40,8 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
build-args: |
ZIPLINE_GIT_SHA=${{ steps.sha.outputs.short_sha }}
tags: |
ghcr.io/diced/zipline:trunk-${{ matrix.arch }}
ghcr.io/diced/zipline:trunk-${{ steps.sha.outputs.short_sha }}-${{ matrix.arch }}

17
.gitignore vendored
View File

@@ -13,16 +13,14 @@
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
build/
# misc
.DS_Store
*.pem
.idea
.vscode
# debug
npm-debug.log*
@@ -37,12 +35,17 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# eslint
.eslintcache
# nix dev env
!.envrc
.direnv
.devenv
# zipline
uploads*/
*.crt
*.key
*.key
src/prisma

1
.prettierignore Executable file
View File

@@ -0,0 +1 @@
pnpm-lock.yaml

View File

@@ -3,9 +3,7 @@ FROM node:22-alpine3.21 AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g corepack
RUN corepack enable pnpm
RUN corepack prepare pnpm@latest --activate
RUN corepack enable
RUN apk add --no-cache ffmpeg tzdata
@@ -22,14 +20,19 @@ FROM base AS builder
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
COPY src ./src
COPY next.config.js ./next.config.js
COPY .gitignore ./.gitignore
COPY postcss.config.cjs ./postcss.config.cjs
COPY prettier.config.cjs ./prettier.config.cjs
COPY eslint.config.mjs ./eslint.config.mjs
COPY vite.config.ts ./vite.config.ts
COPY tsup.config.ts ./tsup.config.ts
COPY tsconfig.json ./tsconfig.json
COPY mimes.json ./mimes.json
COPY code.json ./code.json
COPY vite-env.d.ts ./vite-env.d.ts
ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production
ENV NODE_ENV=production
RUN ZIPLINE_BUILD=true pnpm run build
@@ -38,7 +41,6 @@ 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
@@ -50,4 +52,7 @@ RUN rm -rf /tmp/* /root/*
ENV NODE_ENV=production
ARG ZIPLINE_GIT_SHA
ENV ZIPLINE_GIT_SHA=${ZIPLINE_GIT_SHA:-"unknown"}
CMD ["node", "--enable-source-maps", "build/server"]

View File

@@ -77,11 +77,17 @@ services:
environment:
- DATABASE_URL=postgres://${POSTGRESQL_USER:-zipline}:${POSTGRESQL_PASSWORD}@postgresql:5432/${POSTGRESQL_DB:-zipline}
depends_on:
- postgresql
postgresql:
condition: service_healthy
volumes:
- './uploads:/zipline/uploads'
- './public:/zipline/public'
- './themes:/zipline/themes'
healthcheck:
test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3000/api/healthcheck']
interval: 15s
timeout: 2s
retries: 2
volumes:
pgdata:
@@ -192,6 +198,32 @@ 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 [direnv](https://direnv.net/) installed, you can simply cd into the cloned directory and run the following command:
```bash
direnv allow
```
Granted that you have direnv setup properly, you will now be in a new nix shell with all the dependencies and PostgreSQL installed.
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. |
| `pgdown` | Stops the postgres server running in the background |
| `pg_ctl status` | See if the postgres server is running |
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)

View File

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

View File

@@ -26,11 +26,17 @@ services:
environment:
- DATABASE_URL=postgres://${POSTGRESQL_USER:-zipline}:${POSTGRESQL_PASSWORD}@postgresql:5432/${POSTGRESQL_DB:-zipline}
depends_on:
- postgresql
postgresql:
condition: service_healthy
volumes:
- './uploads:/zipline/uploads'
- './public:/zipline/public'
- './themes:/zipline/themes'
healthcheck:
test: ['CMD', 'wget', '-q', '--spider', 'http://0.0.0.0:3000/api/healthcheck']
interval: 15s
timeout: 2s
retries: 2
volumes:
pgdata:

View File

@@ -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
View 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
View 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;
};
};
};
};
}

View File

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

View File

@@ -2,112 +2,126 @@
"name": "zipline",
"private": true,
"license": "MIT",
"version": "4.0.1",
"version": "4.2.3",
"scripts": {
"build": "cross-env pnpm run --stream \"/^build:.*/\"",
"lint": "eslint .",
"build:skip": "pnpm run --stream build:prisma && pnpm run --stream build:server && pnpm run --stream build:client",
"build": "pnpm run --stream lint && pnpm run --stream build:prisma && pnpm run --stream build:types && pnpm run --stream build:server && pnpm run --stream build:client",
"build:types": "tsc",
"build:prisma": "prisma generate --no-hints",
"build:next": "ZIPLINE_BUILD=true next build",
"build:server": "tsup",
"dev": "cross-env TURBOPACK=1 NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:inspector": "cross-env TURBOPACK=1 NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
"start": "cross-env NODE_ENV=production node --trace-warnings --require dotenv/config --enable-source-maps ./build/server",
"build:client": "vite build && pnpm run --stream \"/^build-ssr:.*/\"",
"build-ssr:view": "vite build --ssr ssr-view/server.tsx -m ssr-view --outDir ../../build/ssr --emptyOutDir=false",
"build-ssr:view-url": "vite build --ssr ssr-view-url/server.tsx -m ssr-view-url --outDir ../../build/ssr --emptyOutDir=false",
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:nd": "cross-env NODE_ENV=development tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:inspector": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
"start": "cross-env NODE_ENV=production node --trace-warnings --require dotenv/config ./build/server",
"start:inspector": "cross-env NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
"ctl": "NODE_ENV=production node --require dotenv/config --enable-source-maps ./build/ctl",
"validate": "pnpm run --stream \"/^validate:.*/\"",
"validate:lint": "eslint --cache --fix .",
"validate:format": "prettier --write --ignore-path .gitignore .",
"db:prototype": "prisma db push --skip-generate && prisma generate --no-hints",
"db:migrate": "prisma migrate dev --create-only"
"db:migrate": "prisma migrate dev --create-only",
"docker:engine": "colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w",
"docker:compose:dev:build": "docker-compose --file docker-compose.dev.yml build --build-arg ZIPLINE_GIT_SHA=$(git rev-parse HEAD)",
"docker:compose:dev:up": "docker-compose --file docker-compose.dev.yml up -d",
"docker:compose:dev:down": "docker-compose --file docker-compose.dev.yml down",
"docker:compose:dev:logs": "docker-compose --file docker-compose.dev.yml logs -f"
},
"dependencies": {
"@aws-sdk/client-s3": "3.726.1",
"@aws-sdk/client-s3": "3.859.0",
"@aws-sdk/lib-storage": "3.859.0",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^10.0.2",
"@fastify/cors": "^11.1.0",
"@fastify/multipart": "^9.0.3",
"@fastify/rate-limit": "^10.2.2",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.1.1",
"@fastify/static": "^8.2.0",
"@github/webauthn-json": "^2.1.1",
"@mantine/charts": "^7.17.0",
"@mantine/code-highlight": "^7.17.0",
"@mantine/core": "^7.17.0",
"@mantine/dates": "^7.17.0",
"@mantine/dropzone": "^7.17.0",
"@mantine/form": "^7.17.0",
"@mantine/hooks": "^7.17.0",
"@mantine/modals": "^7.17.0",
"@mantine/notifications": "^7.17.0",
"@prisma/client": "^6.4.1",
"@prisma/internals": "^6.4.1",
"@prisma/migrate": "^6.4.1",
"@tabler/icons-react": "^3.30.0",
"@xoi/gps-metadata-remover": "^1.1.2",
"argon2": "^0.41.1",
"@mantine/charts": "^8.2.2",
"@mantine/code-highlight": "^8.2.2",
"@mantine/core": "^8.2.2",
"@mantine/dates": "^8.2.2",
"@mantine/dropzone": "^8.2.2",
"@mantine/form": "^8.2.2",
"@mantine/hooks": "^8.2.2",
"@mantine/modals": "^8.2.2",
"@mantine/notifications": "^8.2.2",
"@prisma/adapter-pg": "^6.13.0",
"@prisma/client": "^6.13.0",
"@prisma/internals": "^6.13.0",
"@prisma/migrate": "^6.13.0",
"@smithy/node-http-handler": "^4.1.0",
"@tabler/icons-react": "^3.34.1",
"argon2": "^0.43.1",
"bytes": "^3.1.2",
"clsx": "^2.1.1",
"colorette": "^2.0.20",
"commander": "^13.1.0",
"cross-env": "^7.0.3",
"commander": "^14.0.0",
"cookie": "^1.0.2",
"cross-env": "^10.0.0",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"dotenv": "^17.2.1",
"fast-glob": "^3.3.3",
"fastify": "^5.2.1",
"fastify": "^5.4.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.22.0",
"katex": "^0.16.21",
"mantine-datatable": "^7.15.1",
"isomorphic-dompurify": "^2.26.0",
"katex": "^0.16.22",
"mantine-datatable": "^8.2.0",
"ms": "^2.1.3",
"multer": "1.4.5-lts.1",
"next": "^15.1.7",
"multer": "2.0.2",
"otplib": "^12.0.1",
"prisma": "^6.4.1",
"prisma": "^6.13.0",
"qrcode": "^1.5.4",
"react": "^19.0.0-rc.1",
"react-dom": "^19.0.0-rc.1",
"react-markdown": "^10.0.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.7.1",
"remark-gfm": "^4.0.1",
"sharp": "^0.33.5",
"swr": "^2.3.2",
"zod": "^3.24.2",
"zustand": "^5.0.3"
"sharp": "^0.34.3",
"swr": "^2.3.4",
"typescript-eslint": "^8.39.0",
"vite": "^7.1.0",
"zod": "^3.25.67",
"zustand": "^5.0.7"
},
"devDependencies": {
"@eslint/compat": "^1.2.7",
"@eslint/eslintrc": "^3.3.0",
"@eslint/js": "^9.21.0",
"@types/bytes": "^3.1.5",
"@types/express": "^5.0.0",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/katex": "^0.16.7",
"@types/ms": "^2.1.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.13.5",
"@types/multer": "^2.0.0",
"@types/node": "^24.2.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@typescript-eslint/eslint-plugin": "^8.24.1",
"@typescript-eslint/parser": "^8.24.1",
"eslint": "^9.21.0",
"eslint-config-next": "^15.1.7",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.3",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.32.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.1.4",
"postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0",
"postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.5.2",
"tsc-alias": "^1.8.10",
"tsup": "^8.3.6",
"tsx": "^4.19.3",
"typescript": "^5.7.3"
"prettier": "^3.6.2",
"sass": "^1.90.0",
"tsc-alias": "^1.8.16",
"tsup": "^8.5.0",
"tsx": "^4.20.3",
"typescript": "^5.9.2"
},
"engines": {
"node": ">=22"
},
"packageManager": "pnpm@10.2.0"
"packageManager": "pnpm@10.12.1"
}

7014
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

10
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,10 @@
ignoredBuiltDependencies:
- unrs-resolver
onlyBuiltDependencies:
- '@parcel/watcher'
- '@prisma/client'
- '@prisma/engines'
- argon2
- esbuild
- prisma
- sharp

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Zipline" ADD COLUMN "featuresVersionAPI" TEXT NOT NULL DEFAULT 'https://zipline-version.diced.sh',
ADD COLUMN "featuresVersionChecking" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Zipline" ADD COLUMN "oauthDiscordWhitelistIds" TEXT[] DEFAULT ARRAY[]::TEXT[];

View File

@@ -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[];

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Zipline" ADD COLUMN "domains" TEXT[] DEFAULT ARRAY[]::TEXT[];

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
provider = "postgresql"

View File

@@ -1,5 +1,8 @@
generator client {
provider = "prisma-client-js"
provider = "prisma-client"
output = "../src/prisma"
moduleFormat = "cjs"
previewFeatures = ["queryCompiler", "driverAdapters"]
}
datasource db {
@@ -57,6 +60,9 @@ model Zipline {
featuresMetricsAdminOnly Boolean @default(false)
featuresMetricsShowUserSpecific Boolean @default(true)
featuresVersionChecking Boolean @default(true)
featuresVersionAPI String @default("https://zipline-version.diced.sh")
invitesEnabled Boolean @default(true)
invitesLength Int @default(6)
@@ -78,6 +84,8 @@ model Zipline {
oauthDiscordClientId String?
oauthDiscordClientSecret String?
oauthDiscordRedirectUri String?
oauthDiscordAllowedIds String[] @default([])
oauthDiscordDeniedIds String[] @default([])
oauthGoogleClientId String?
oauthGoogleClientSecret String?
@@ -129,6 +137,8 @@ model Zipline {
pwaDescription String @default("Zipline")
pwaThemeColor String @default("#000000")
pwaBackgroundColor String @default("#000000")
domains String[] @default([])
}
model User {

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

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

@@ -1,11 +1,8 @@
import ExternalAuthButton from '@/components/pages/login/ExternalAuthButton';
import { Response } from '@/lib/api/response';
import { SafeConfig } from '@/lib/config/safe';
import { getZipline } from '@/lib/db/models/zipline';
import { fetchApi } from '@/lib/fetchApi';
import { withSafeConfig } from '@/lib/middleware/next/withSafeConfig';
import useLogin from '@/lib/hooks/useLogin';
import { authenticateWeb } from '@/lib/passkey';
import { eitherTrue } from '@/lib/primitive';
import {
Button,
Center,
@@ -34,28 +31,43 @@ import {
IconUserPlus,
IconX,
} from '@tabler/icons-react';
import { InferGetServerSidePropsType } from 'next';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { Link, redirect, useLocation, useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import GenericError from '../../error/GenericError';
import { useTitle } from '@/lib/hooks/useTitle';
export default function Login({ config }: InferGetServerSidePropsType<typeof getServerSideProps>) {
const router = useRouter();
const { data, isLoading, mutate } = useSWR<Response['/api/user']>('/api/user', {
refreshInterval: 120000,
export default function Login() {
useTitle('Login');
const location = useLocation();
const query = new URLSearchParams(location.search);
const { user, mutate } = useLogin();
const navigate = useNavigate();
const {
data: config,
error: configError,
isLoading: configLoading,
} = useSWR<Response['/api/server/public']>('/api/server/public', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
});
const showLocalLogin =
router.query.local === 'true' ||
query.get('local') === 'true' ||
!(
config.oauth.bypassLocalLogin && Object.values(config.oauthEnabled).filter((x) => x === true).length > 0
config?.oauth?.bypassLocalLogin &&
Object.values(config?.oauthEnabled ?? {}).filter((x) => x === true).length > 0
);
const willRedirect =
config.oauth.bypassLocalLogin &&
Object.values(config.oauthEnabled).filter((x) => x === true).length === 1 &&
router.query.local !== 'true';
config?.oauth?.bypassLocalLogin &&
Object.values(config?.oauthEnabled ?? {}).filter((x) => x === true).length === 1 &&
query.get('local') !== 'true';
const [totpOpen, setTotpOpen] = useState(false);
const [pinDisabled, setPinDisabled] = useState(false);
@@ -65,12 +77,6 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
const [passkeyErrored, setPasskeyErrored] = useState(false);
const [passkeyLoading, setPasskeyLoading] = useState(false);
useEffect(() => {
if (data?.user) {
router.push('/dashboard');
}
}, [data]);
const form = useForm({
initialValues: {
username: '',
@@ -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(),
});
@@ -137,23 +143,30 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
} else {
mutate(data as Response['/api/user']);
}
} catch {
} catch (e) {
console.log(e);
setPasskeyErrored(true);
setPasskeyLoading(false);
}
};
useEffect(() => {
if (willRedirect) {
if (user) {
navigate('/dashboard');
}
}, [user]);
useEffect(() => {
if (willRedirect && config) {
const provider = Object.keys(config.oauthEnabled).find(
(x) => config.oauthEnabled[x as keyof SafeConfig['oauthEnabled']] === true,
(x) => config.oauthEnabled[x as keyof typeof config.oauthEnabled] === true,
);
if (provider) {
router.push(`/api/auth/oauth/${provider}`);
redirect(`/api/auth/oauth/${provider.toLowerCase()}`);
}
}
}, []);
}, [willRedirect, config]);
useEffect(() => {
if (passkeyErrored) {
@@ -170,6 +183,23 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
}
}, [passkeyErrored]);
useEffect(() => {
if (config?.firstSetup) navigate('/auth/setup');
}, [config]);
if (configLoading) return <LoadingOverlay visible />;
if (configError)
return (
<GenericError
title='Error loading configuration'
message='Could not load server configuration...'
details={configError}
/>
);
if (!config) return <LoadingOverlay visible />;
return (
<>
{willRedirect && !showLocalLogin && <LoadingOverlay visible />}
@@ -250,56 +280,59 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
<div style={{ width: '100%', overflowWrap: 'break-word' }}>
<Title
order={1}
size={(config.website.title ?? 'Zipline').length > 50 ? 20 : 50}
ta='center'
style={{ whiteSpace: 'normal' }}
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>
{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' />
)}
@@ -320,7 +353,7 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
{config.features.userRegistration && (
<Button
component={Link}
href='/auth/register'
to='/auth/register'
size='md'
fullWidth
variant='outline'
@@ -329,6 +362,7 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
Sign up
</Button>
)}
<Group grow>
{config.oauthEnabled.discord && (
<ExternalAuthButton
@@ -355,19 +389,3 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
</>
);
}
export const getServerSideProps = withSafeConfig(async () => {
const { firstSetup } = await getZipline();
if (firstSetup)
return {
redirect: {
destination: '/setup',
permanent: false,
},
};
return {};
});
Login.title = 'Login';

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
import ViewUserFiles from '@/components/pages/users/ViewUserFiles';
import { useTitle } from '@/lib/hooks/useTitle';
import { Params, redirect, useLoaderData } from 'react-router-dom';
export async function loader({ params }: { params: Params<string> }) {
const res = await fetch('/api/users/' + params.id);
if (!res.ok) {
console.log("can't get user", res.status);
return redirect('/dashboard/admin/users');
}
const user = await res.json();
return { user };
}
export function Component() {
const { user } = useLoaderData<typeof loader>();
useTitle(`${user ? user.username : 'User'}'s files`);
return <ViewUserFiles />;
}
Component.displayName = 'DashboardAdminViewUserFiles';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import type { SafeConfig } from '@/lib/config/safe';
import { fetchApi } from '@/lib/fetchApi';
import useAvatar from '@/lib/hooks/useAvatar';
import useLogin from '@/lib/hooks/useLogin';
import { Outlet, useLocation } from 'react-router-dom';
import { isAdministrator } from '@/lib/role';
import { useUserStore } from '@/lib/store/user';
import {
@@ -44,10 +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;
@@ -141,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();
@@ -274,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>
@@ -283,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>
@@ -294,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>
@@ -321,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 {
@@ -334,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(
@@ -347,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>
@@ -357,29 +366,33 @@ export default function Layout({ children, config }: { children: React.ReactNode
}
})}
<Divider mt='auto' />
<div style={{ marginTop: 'auto' }}>
<VersionBadge />
<ScrollArea mah='auto'>
<Box>
{config.website.externalLinks.map(({ name, url }, i) => (
<NavLink
key={i}
label={name}
leftSection={<IconExternalLink size='1rem' />}
variant='light'
component={Link}
href={url}
target='_blank'
/>
))}
</Box>
</ScrollArea>
<Divider />
<ScrollArea mah='auto'>
<Box>
{config.website.externalLinks.map(({ name, url }, i) => (
<NavLink
key={i}
label={name}
leftSection={<IconExternalLink size='1rem' />}
variant='light'
component={Link}
to={url}
target='_blank'
/>
))}
</Box>
</ScrollArea>
</div>
</AppShell.Navbar>
<AppShell.Main>
<ConfigProvider config={config}>
<ConfigProvider data={loaderData}>
<Paper m='lg' withBorder p='xs'>
{children}
<Outlet />
</Paper>
</ConfigProvider>
</AppShell.Main>

View File

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

View File

@@ -0,0 +1,173 @@
import useVersion from '@/lib/hooks/useVersion';
import {
Anchor,
Badge,
Button,
Flex,
Indicator,
Modal,
Paper,
Stack,
Text,
Title,
Tooltip,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
function DataDisplay({ items }: { items: { label: string; value: string; href?: string }[] }) {
return (
<Paper withBorder p='sm'>
<Stack gap='xs'>
{items.map((item, index) => (
<Flex justify='space-between' align='center' style={{ width: '100%' }} key={index}>
<Text c='dimmed' fw='bolder' style={{ flex: 1 }}>
{item.label}
</Text>
{item.href ? (
<Anchor href={item.href} target='_blank'>
{item.value}
</Anchor>
) : (
<Text>{item.value}</Text>
)}
</Flex>
))}
</Stack>
</Paper>
);
}
function VersionButton({ text, children, href }: { href: string; text: string; children: React.ReactNode }) {
return (
<Button
component='a'
href={href}
target='_blank'
variant='filled'
fullWidth
color='blue'
size='sm'
mt='xs'
leftSection={
<Text size='sm' fw='bolder'>
{text}
</Text>
}
>
{children}
</Button>
);
}
export default function VersionBadge() {
const { version, isLoading } = useVersion();
const [opened, { open, close }] = useDisclosure(false);
if (isLoading) return null;
if (!version) return null;
return (
<>
<Modal title='Zipline Version' opened={opened} onClose={close} size='lg'>
{version.isLatest && <Text>Running the latest version of Zipline.</Text>}
{version.isUpstream && (
<Text>
You are running an <b>unstable</b> version of Zipline. Upstream versions are not fully tested and
may contain bugs.
</Text>
)}
{!version.isLatest && !version.isUpstream && version.isRelease && (
<Text>
You are running an <b>outdated</b> version of Zipline. It is recommended to update to the{' '}
<Anchor href={version.latest.url}>latest version</Anchor>.
</Text>
)}
<Indicator
processing
position='middle-end'
inline
offset={-15}
color='red'
disabled={version.isLatest}
>
<Title order={3} my='sm'>
Current Version
</Title>
</Indicator>
<DataDisplay
items={[
{
label: 'Version',
value: version.version.tag!,
href: `https://github.com/diced/zipline/releases/${version.version.tag}`,
},
{
label: 'Commit',
value: version.version.sha!,
href: `https://github.com/diced/zipline/commit/${version.version.sha}`,
},
{ label: 'Upstream?', value: version.isUpstream ? 'Yes' : 'No' },
]}
/>
{!version.isLatest && version.isUpstream && version.latest.commit && (
<>
<Title order={3} mt='sm'>
Latest Commit Available
</Title>
<Text c='dimmed' size='sm' mb='sm'>
This is only visible when running an upstream version.
</Text>
<DataDisplay
items={[
{
label: 'Commit',
value: version.latest.commit.sha!.slice(0, 7)!,
href: `https://github.com/diced/zipline/commit/${version.latest.commit.sha}`,
},
{
label: 'Available to update',
value: version.latest.commit.pull ? 'Yes' : 'No',
},
]}
/>
</>
)}
{!version.isLatest && version.isRelease && (
<>
<Title order={3} mt='sm'>
{version.latest.tag} is available
</Title>
<VersionButton text='Changelogs' href={version.latest.url}>
{version.latest.tag}
</VersionButton>
<VersionButton text='Update' href='https://zipline.diced.sh/docs/get-started/docker#updating'>
{version.latest.tag}
</VersionButton>
</>
)}
</Modal>
<Tooltip label='Click to view more version information'>
<Badge
onClick={open}
style={{ cursor: 'pointer', textTransform: 'unset' }}
mx='sm'
my='xs'
color={version.isLatest ? 'green' : 'red'}
variant='dot'
size='lg'
radius='md'
>
{version.version?.tag}
</Badge>
</Tooltip>
</>
);
}

View File

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

View File

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

View File

@@ -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 ? (
<>
@@ -305,7 +304,8 @@ export default function FileModal({
onClick={() => removeFromFolder(file)}
fullWidth
>
Remove from folder &quot;{folders?.find((f) => f.id === file.folderId)?.name ?? ''}
Remove from folder &quot;
{folders?.find((f: { id: string }) => f.id === file.folderId)?.name ?? ''}
&quot;
</Button>
) : (
@@ -337,18 +337,21 @@ export default function FileModal({
<Combobox.Dropdown>
<Combobox.Options>
{folders
?.filter((f) => f.name.toLowerCase().includes(search.toLowerCase().trim()))
.map((f) => (
?.filter((f: { name: string }) =>
f.name.toLowerCase().includes(search.toLowerCase().trim()),
)
.map((f: { name: string; id: string }) => (
<Combobox.Option value={f.id} key={f.id}>
{f.name}
</Combobox.Option>
))}
{!folders?.some((f) => f.name === search) && search.trim().length > 0 && (
<Combobox.Option value='$create'>
+ Create folder &quot;{search}&quot;
</Combobox.Option>
)}
{!folders?.some((f: { name: string }) => f.name === search) &&
search.trim().length > 0 && (
<Combobox.Option value='$create'>
+ Create folder &quot;{search}&quot;
</Combobox.Option>
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>

View File

@@ -11,7 +11,7 @@ import {
Text,
} from '@mantine/core';
import { Icon, IconFileUnknown, IconPlayerPlay, IconShieldLockFilled } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { renderMode } from '../pages/upload/renderMode';
import Render from '../render/Render';
import fileIcon from './fileIcon';
@@ -29,7 +29,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>
);
@@ -73,69 +73,80 @@ export default function DashboardFileType({
}: {
file: DbFile | File;
show?: boolean;
password?: string;
password?: string | null;
code?: boolean;
allowZoom?: boolean;
}) {
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
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 [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(`/raw/${file.name}${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(`/raw/${file.name}${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();
getText();
} else if (type === 'text') {
gettext();
getText();
} else {
return;
}
}, []);
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
}, [open]);
if (disableMediaPreview && !show)
return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
@@ -162,11 +173,14 @@ export default function DashboardFileType({
muted
controls
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
style={{ cursor: 'pointer' }}
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={`/raw/${(file as DbFile).thumbnail!.path}`}
alt={file.name || 'Video thumbnail'}
/>
<Center
pos='absolute'
@@ -191,14 +205,12 @@ export default function DashboardFileType({
return show ? (
<Center>
<MantineImage
mah={400}
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
alt={file.name}
alt={file.name || 'Image'}
style={{
maxWidth: '90vw',
maxHeight: '90vh',
cursor: allowZoom ? 'zoom-in' : 'default',
width: 'auto',
maxWidth: '70vw',
maxHeight: '70vw',
}}
onClick={() => setOpen(true)}
/>
@@ -208,10 +220,10 @@ export default function DashboardFileType({
src={
dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)
}
alt={file.name}
alt={file.name || 'Image'}
style={{
maxWidth: '90vw',
maxHeight: '90vh',
maxWidth: '95vw',
maxHeight: '95vh',
objectFit: 'contain',
cursor: 'zoom-out',
width: 'auto',
@@ -225,7 +237,7 @@ export default function DashboardFileType({
fit='contain'
mah={400}
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
alt={file.name}
alt={file.name || 'Image'}
/>
);
case 'audio':

View File

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

View File

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

View File

@@ -3,10 +3,9 @@ import { IncompleteFile } from '@/lib/db/models/incompleteFile';
import { fetchApi } from '@/lib/fetchApi';
import { ActionIcon, Badge, Button, Card, Group, Modal, Paper, Stack, Text, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IncompleteFileStatus } from '@prisma/client';
import { IncompleteFileStatus } from '@/prisma/client';
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { ReactNode, useEffect, useState } from 'react';
import { ReactNode, useState } from 'react';
import useSWR from 'swr';
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
@@ -33,9 +32,7 @@ const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
};
export default function PendingFilesButton() {
const router = useRouter();
const [open, setOpen] = useState(router.query.pending !== undefined);
const [open, setOpen] = useState(false);
const { data: incompleteFiles, mutate } = useSWR<
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
@@ -68,15 +65,6 @@ export default function PendingFilesButton() {
mutate();
};
useEffect(() => {
if (open) {
router.push({ query: { ...router.query, pending: 'true' } }, undefined, { shallow: true });
} else {
delete router.query.pending;
router.push({ query: router.query }, undefined, { shallow: true });
}
}, [open]);
return (
<>
<Modal opened={open} onClose={() => setOpen(false)} title='Pending Files'>

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ export default function CreateTagModal({ open, onClose }: { open: boolean; onClo
const color = values.color.trim() === '' ? colorHash(values.name) : values.color.trim();
if (!color.startsWith('#')) {
form.setFieldError('color', 'Color must start with #');
return form.setFieldError('color', 'Color must start with #');
}
const { data, error } = await fetchApi<Extract<Response['/api/user/tags'], Tag>>(

View File

@@ -1,11 +1,10 @@
import { Tag } from '@/lib/db/models/tag';
import { Pill, isLightColor } from '@mantine/core';
export default function TagPill({
tag,
...other
}: {
tag: Tag | null;
tag: { color: string; name: string } | null;
withRemoveButton?: boolean;
onRemove?: () => void;
}) {

View File

@@ -1,21 +1,18 @@
import { mutateFiles } from '@/components/file/actions';
import { Response } from '@/lib/api/response';
import { Tag } from '@/lib/db/models/tag';
import { ActionIcon, Group, Modal, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
import { IconPencil, IconPlus, IconTagOff, IconTags, IconTrashFilled } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import TagPill from './TagPill';
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 { useState } from 'react';
import useSWR from 'swr';
import CreateTagModal from './CreateTagModal';
import EditTagModal from './EditTagModal';
import { mutateFiles } from '@/components/file/actions';
import TagPill from './TagPill';
export default function TagsButton() {
const router = useRouter();
const [open, setOpen] = useState(router.query.tags !== undefined);
const [open, setOpen] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
@@ -44,15 +41,6 @@ export default function TagsButton() {
mutateFiles();
};
useEffect(() => {
if (open) {
router.push({ query: { ...router.query, tags: 'true' } }, undefined, { shallow: true });
} else {
delete router.query.tags;
router.push({ query: router.query }, undefined, { shallow: true });
}
}, [open]);
return (
<>
<CreateTagModal open={createModalOpen} onClose={() => setCreateModalOpen(false)} />

View File

@@ -1,5 +1,4 @@
import { Response } from '@/lib/api/response';
import type { Prisma } from '@prisma/client';
import useSWR from 'swr';
type ApiPaginationOptions = {
@@ -7,7 +6,17 @@ type ApiPaginationOptions = {
filter?: string;
perpage?: number;
favorite?: boolean;
sort?: keyof Prisma.FileOrderByWithAggregationInput;
sort?:
| 'name'
| 'id'
| 'createdAt'
| 'updatedAt'
| 'deletesAt'
| 'originalName'
| 'size'
| 'type'
| 'views'
| 'favorite';
order?: 'asc' | 'desc';
id?: string;
search?: {

View File

@@ -1,3 +1,4 @@
import { useQueryState } from '@/lib/hooks/useQueryState';
import {
Accordion,
Button,
@@ -12,47 +13,21 @@ import {
Title,
} from '@mantine/core';
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useApiPagination } from '../useApiPagination';
import dynamic from 'next/dynamic';
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 router = useRouter();
const [page, setPage] = useQueryState('fpage', 1);
const [page, setPage] = useState<number>(
router.query.favoritePage ? parseInt(router.query.favoritePage as string) : 1,
);
const { data, isLoading } = useApiPagination({
page,
favorite: true,
filter: 'dashboard',
});
useEffect(() => {
router.replace(
{
query: {
...router.query,
favoritePage: page,
},
},
undefined,
{ shallow: true },
);
}, [page]);
if (!isLoading && !data?.page.length && router.query.favoritePage) {
delete router.query.favoritePage;
router.replace({ query: router.query }, undefined, { shallow: true });
setPage(1);
}
if (!isLoading && !data?.page.length) {
return null;
}
@@ -78,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>
@@ -92,7 +71,7 @@ export default function FavoriteFiles() {
size='compact-sm'
leftSection={<IconFileUpload size='1rem' />}
component={Link}
href='/dashboard/upload/file'
to='/dashboard/upload/file'
>
Upload a file
</Button>

View File

@@ -1,6 +1,5 @@
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';
@@ -28,9 +27,9 @@ import {
useCombobox,
} from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import type { Prisma } from '@prisma/client';
import {
IconCopy,
IconDownload,
IconExternalLink,
IconFile,
IconGridPatternFilled,
@@ -38,13 +37,15 @@ import {
IconTrashFilled,
} from '@tabler/icons-react';
import { DataTable } from 'mantine-datatable';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useReducer, useState } from 'react';
import { lazy, useEffect, useReducer, useState } from 'react';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
import { bulkDelete, bulkFavorite } from '../bulk';
import TagPill from '../tags/TagPill';
import { useApiPagination } from '../useApiPagination';
import { useQueryState } from '@/lib/hooks/useQueryState';
const FileModal = lazy(() => import('@/components/file/DashboardFile/FileModal'));
type ReducerQuery = {
state: { name: string; originalName: string; type: string; tags: string; id: string };
@@ -179,7 +180,6 @@ function TagsFilter({
}
export default function FileTable({ id }: { id?: string }) {
const router = useRouter();
const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
@@ -187,9 +187,20 @@ export default function FileTable({ id }: { id?: string }) {
'/api/user/folders?noincl=true',
);
const [page, setPage] = useState<number>(router.query.page ? parseInt(router.query.page as string) : 1);
const [perpage, setPerpage] = useState<number>(20);
const [sort, setSort] = useState<keyof Prisma.FileOrderByWithAggregationInput>('createdAt');
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);
@@ -253,19 +264,6 @@ export default function FileTable({ id }: { id?: string }) {
}),
});
useEffect(() => {
router.replace(
{
query: {
...router.query,
page: page,
},
},
undefined,
{ shallow: true },
);
}, [page]);
useEffect(() => {
if (data && selectedFile) {
const file = data.page.find((x) => x.id === selectedFile.id);
@@ -304,9 +302,8 @@ export default function FileTable({ id }: { id?: string }) {
onClick={() => {
setIdSearchOpen((open) => !open);
}}
color='blue'
// lol if it works it works :shrug:
style={{ position: 'relative', top: '-36.4px', left: '219px', margin: 0 }}
style={{ position: 'relative', top: '-36.4px', left: '221px', margin: 0 }}
>
<IconGridPatternFilled size='1rem' />
</ActionIcon>
@@ -479,6 +476,11 @@ export default function FileTable({ id }: { id?: string }) {
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() === '',
@@ -496,7 +498,7 @@ export default function FileTable({ id }: { id?: string }) {
</Tooltip>
<Tooltip label='View file in new tab'>
<Link href={`/view/${file.name}`} target='_blank'>
<Link to={`/view/${file.name}`} target='_blank'>
<ActionIcon color='blue'>
<IconExternalLink size='1rem' />
</ActionIcon>
@@ -514,6 +516,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'
@@ -541,7 +555,7 @@ export default function FileTable({ id }: { id?: string }) {
direction: order,
}}
onSortStatusChange={(data) => {
setSort(data.columnAccessor as keyof Prisma.FileOrderByWithAggregationInput);
setSort(data.columnAccessor as any);
setOrder(data.direction);
}}
onCellClick={({ record }) => setSelectedFile(record)}

View File

@@ -1,44 +1,46 @@
import { Button, Center, Group, Pagination, Paper, SimpleGrid, Skeleton, Stack, Title } from '@mantine/core';
import {
Button,
Center,
Group,
Pagination,
Paper,
Select,
SimpleGrid,
Skeleton,
Stack,
Text,
Title,
} from '@mantine/core';
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { useRouter } from 'next/router';
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 router = useRouter();
const [page, setPage] = useQueryState('page', 1);
const [perpage, setPerpage] = useState(15);
const [cachedPages, setCachedPages] = useState(1);
const [page, setPage] = useState<number>(router.query.page ? parseInt(router.query.page as string) : 1);
const { data, isLoading } = useApiPagination({
page,
perpage,
id,
});
const [cachedPages, setCachedPages] = useState<number>(1);
useEffect(() => {
if (data?.pages) {
setCachedPages(data.pages);
}
}, [data?.pages]);
useEffect(() => {
router.replace(
{
query: {
...router.query,
page: page,
},
},
undefined,
{ shallow: true },
);
}, [page]);
const from = (page - 1) * perpage + 1;
const to = Math.min(page * perpage, data?.total ?? 0);
const totalRecords = data?.total ?? 0;
return (
<>
@@ -55,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>
@@ -70,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>
@@ -81,9 +87,25 @@ export default function Files({ id }: { id?: string }) {
)}
</SimpleGrid>
<Center>
<Pagination my='sm' value={page} onChange={setPage} total={cachedPages} />
</Center>
<Group justify='space-between' align='center' mt='md'>
<Text size='sm'>{`${from} - ${to} / ${totalRecords} files`}</Text>
<Group gap='sm'>
<Select
value={perpage.toString()}
data={PER_PAGE_OPTIONS.map((val) => ({ value: val.toString(), label: `${val}` }))}
onChange={(value) => {
setPerpage(Number(value));
setPage(1);
}}
w={80}
size='xs'
variant='filled'
/>
<Pagination value={page} onChange={setPage} total={cachedPages} size='sm' withControls withEdges />
</Group>
</Group>
</>
);
}

View File

@@ -1,4 +1,4 @@
import DashboardFile from '@/components/file/DashboardFile';
import { useQueryState } from '@/lib/hooks/useQueryState';
import {
Accordion,
Button,
@@ -8,40 +8,25 @@ import {
Pagination,
Paper,
SimpleGrid,
Skeleton,
Stack,
Title,
} from '@mantine/core';
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
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 router = useRouter();
const [page, setPage] = useState<number>(
router.query.favoritePage ? parseInt(router.query.favoritePage as string) : 1,
);
const [page, setPage] = useQueryState('fpage', 1);
const { data, isLoading } = useApiPagination({
page,
favorite: true,
filter: 'dashboard',
});
useEffect(() => {
router.replace(
{
query: {
...router.query,
favoritePage: page,
},
},
undefined,
{ shallow: true },
);
}, [page]);
if (!isLoading && data?.page.length === 0) return null;
return (
@@ -65,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>
@@ -79,7 +68,7 @@ export default function FavoriteFiles() {
size='compact-sm'
leftSection={<IconFileUpload size='1rem' />}
component={Link}
href='/dashboard/upload/file'
to='/dashboard/upload/file'
>
Upload a file
</Button>

View File

@@ -1,7 +1,9 @@
import DashboardFile from '@/components/file/DashboardFile';
import { Folder } from '@/lib/db/models/folder';
import { Alert, Anchor, Button, CopyButton, Group, Modal, SimpleGrid, Text } from '@mantine/core';
import { Alert, Anchor, Button, CopyButton, Group, Modal, SimpleGrid, Skeleton, Text } from '@mantine/core';
import { IconShare } from '@tabler/icons-react';
import { lazy, Suspense } from 'react';
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
export default function ViewFilesModal({
folder,
@@ -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>
)}

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,16 @@
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';
export default function DashboardInvites() {
const view = useViewStore((state) => state.invites);

View File

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

View File

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

View File

@@ -1,34 +1,36 @@
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 [dateRange, setDateRange] = useState<[Date | null, Date | null]>([
new Date(Date.now() - 86400000 * 7),
new Date(),
]); // default: [7 days ago, now]
const today = dayjs();
const [dateRange, setDateRange] = useState<[string | null, string | null]>([
today.subtract(7, 'day').toISOString(),
today.toISOString(),
]);
const [open, setOpen] = useState(false);
const [allTime, setAllTime] = useState(false);
const { data, isLoading } = useApiStats({
from: dateRange[0]?.toISOString() ?? undefined,
to: dateRange[1]?.toISOString() ?? undefined,
from: allTime || !dateRange[0] ? undefined : new Date(dateRange[0]).toISOString(),
to: allTime || !dateRange[1] ? undefined : new Date(dateRange[1]).toISOString(),
all: allTime,
});
const handleDateChange = (value: [Date | null, Date | null]) => {
const handleDateChange = (value: [string | null, string | null]) => {
setAllTime(false);
setDateRange(value);
};
@@ -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(Date.now() + 0)}
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>
@@ -69,25 +103,14 @@ export default function DashboardMetrics() {
</Button>
{!allTime ? (
<Text size='sm' c='dimmed'>
{data?.length ? (
<>
{new Date(data?.[0]?.createdAt).toLocaleDateString()}
{' to '}
{new Date(data?.[data.length - 1]?.createdAt).toLocaleDateString()}
</>
) : (
<>
{dateRange[0]?.toLocaleDateString()}{' '}
{dateRange[1] ? `to ${dateRange[1]?.toLocaleDateString()}` : ''}
</>
)}
{dateRange[0] ? new Date(dateRange[0]).toLocaleDateString() : '—'}
{dateRange[1] ? ` to ${new Date(dateRange[1]).toLocaleDateString()}` : ''}
</Text>
) : (
<Text size='sm' c='dimmed'>
All Time
</Text>
)}
{/* <Tooltip label='This may take longer than usual to load.'> */}
<Tooltip
label={!allTime ? 'This may take longer than usual to load.' : 'You are viewing all time stats.'}
>
@@ -107,22 +130,18 @@ export default function DashboardMetrics() {
{isLoading ? (
<div>
<StatsCardsSkeleton />
<StatsTablesSkeleton />
</div>
) : data?.length ? (
<div>
<StatsCards data={data!} />
<StatsTables data={data!} />
<StatsCards data={data} />
<StatsTables data={data} />
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }}>
<FilesUrlsCountGraph metrics={data!} />
<ViewsGraph metrics={data!} />
<FilesUrlsCountGraph metrics={data} />
<ViewsGraph metrics={data} />
</SimpleGrid>
<div>
<StorageGraph metrics={data!} />
<StorageGraph metrics={data} />
</div>
</div>
) : (

View File

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

View File

@@ -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]);

View File

@@ -2,16 +2,17 @@ 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;
@@ -22,6 +23,9 @@ export default function ServerSettingsCore({
coreDefaultDomain: '',
coreTempDirectory: '/tmp/zipline',
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
}),
});
const onSubmit = async (values: typeof form.values) => {
@@ -31,14 +35,16 @@ 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',
});
}, [data]);

View File

@@ -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]);

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

View File

@@ -1,17 +1,28 @@
import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, NumberInput, Paper, SimpleGrid, Switch, Title } from '@mantine/core';
import {
Anchor,
Button,
LoadingOverlay,
NumberInput,
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 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,
@@ -25,24 +36,33 @@ export default function ServerSettingsFeatures({
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,
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,
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]);
@@ -107,7 +127,7 @@ export default function ServerSettingsFeatures({
description='Shows metrics specific to each user, for all users.'
{...form.getInputProps('featuresMetricsShowUserSpecific', { type: 'checkbox' })}
/>
<div />
<Switch
label='Enable Thumbnails'
description='Enables thumbnail generation for images. Requires a server restart.'
@@ -122,6 +142,30 @@ export default function ServerSettingsFeatures({
max={16}
{...form.getInputProps('featuresThumbnailsNumberThreads')}
/>
<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.'
{...form.getInputProps('featuresVersionChecking', { type: 'checkbox' })}
/>
<TextInput
label='Version API URL'
description={
<>
The URL of the version checking server. The default is{' '}
<Anchor size='xs' href='zipline-version.diced.sh' target='_blank'>
https://zipline-version.diced.sh
</Anchor>
. Visit the{' '}
<Anchor size='xs' href='https://github.com/diced/zipline-version-worker' target='_blank'>
GitHub
</Anchor>{' '}
to host your own version checking server.
</>
}
placeholder='https://zipline-version.diced.sh/'
{...form.getInputProps('featuresVersionAPI')}
/>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>

View File

@@ -12,16 +12,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 ServerSettingsFiles({
export default function Files({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm<{
filesRoute: string;
filesLength: number;
@@ -48,6 +49,9 @@ export default function ServerSettingsFiles({
filesRandomWordsNumAdjectives: 3,
filesRandomWordsSeparator: '-',
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
}),
});
const onSubmit = async (values: typeof form.values) => {
@@ -76,22 +80,24 @@ export default function ServerSettingsFiles({
.filter((ext) => ext !== '');
}
return settingsOnSubmit(router, form)(values);
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {
if (!data) return;
form.setValues({
filesRoute: data?.filesRoute ?? '/u',
filesLength: data?.filesLength ?? 6,
filesDefaultFormat: data?.filesDefaultFormat ?? 'random',
filesDisabledExtensions: data?.filesDisabledExtensions.join(', ') ?? '',
filesMaxFileSize: data?.filesMaxFileSize ?? '100mb',
filesDefaultExpiration: data?.filesDefaultExpiration ?? '',
filesAssumeMimetypes: data?.filesAssumeMimetypes ?? false,
filesDefaultDateFormat: data?.filesDefaultDateFormat ?? 'YYYY-MM-DD_HH:mm:ss',
filesRemoveGpsMetadata: data?.filesRemoveGpsMetadata ?? false,
filesRandomWordsNumAdjectives: data?.filesRandomWordsNumAdjectives ?? 3,
filesRandomWordsSeparator: data?.filesRandomWordsSeparator ?? '-',
filesRoute: data.settings.filesRoute ?? '/u',
filesLength: data.settings.filesLength ?? 6,
filesDefaultFormat: data.settings.filesDefaultFormat ?? 'random',
filesDisabledExtensions: data.settings.filesDisabledExtensions.join(', ') ?? '',
filesMaxFileSize: data.settings.filesMaxFileSize ?? '100mb',
filesDefaultExpiration: data.settings.filesDefaultExpiration ?? '',
filesAssumeMimetypes: data.settings.filesAssumeMimetypes ?? false,
filesDefaultDateFormat: data.settings.filesDefaultDateFormat ?? 'YYYY-MM-DD_HH:mm:ss',
filesRemoveGpsMetadata: data.settings.filesRemoveGpsMetadata ?? false,
filesRandomWordsNumAdjectives: data.settings.filesRandomWordsNumAdjectives ?? 3,
filesRandomWordsSeparator: data.settings.filesRandomWordsSeparator ?? '-',
});
}, [data]);

View File

@@ -2,21 +2,25 @@ import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function ServerSettingsHttpWebhook({
export default function HttpWebhook({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
httpWebhookOnUpload: '',
httpWebhookOnShorten: '',
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
}),
});
const onSubmit = async (values: typeof form.values) => {
@@ -30,15 +34,15 @@ export default function ServerSettingsHttpWebhook({
}
}
return settingsOnSubmit(router, form)(values);
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {
if (!data) return;
form.setValues({
httpWebhookOnUpload: data?.httpWebhookOnUpload ?? '',
httpWebhookOnShorten: data?.httpWebhookOnShorten ?? '',
httpWebhookOnUpload: data.settings.httpWebhookOnUpload ?? '',
httpWebhookOnShorten: data.settings.httpWebhookOnShorten ?? '',
});
}, [data]);

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