Compare commits

...

122 Commits

Author SHA1 Message Date
diced
574bd9114c feat(v4.4.1): version 2026-01-19 17:10:21 -08:00
Radon Rosborough
73c46b875d fix: missing input field names (#963)
* fix: missing input field names

* Use enhanceGetInputProps instead
2026-01-19 17:04:49 -08:00
diced
e21670f292 fix: titles on view pages 2026-01-17 22:23:43 -08:00
Radon Rosborough
09b3ef4e26 fix: typo in docker-compose.yml (#962) 2026-01-17 22:00:26 -08:00
diced
afdee6994e fix: build errors 2026-01-13 22:53:24 -08:00
diced
6f6879c58a fix: #954 2026-01-13 22:50:30 -08:00
diced
66a2f760cf fix: #957 2026-01-13 22:47:40 -08:00
diced
fb3199a9d5 fix: improve on validation 2026-01-13 22:13:09 -08:00
diced
274a84397a fix: workflow 2026-01-10 23:46:16 -08:00
diced
4b585d8634 feat: gen-openapi workflow 2026-01-10 23:43:45 -08:00
diced
260c283872 feat: input validation schemas (very wip) 2026-01-10 23:32:59 -08:00
diced
4d978c11b1 fix: regen random-values 2026-01-10 15:13:38 -08:00
diced
8bdd9e8315 fix: settings -> domains logic 2026-01-09 21:22:21 -08:00
diced
d4a3e877d2 fix: #956 2026-01-09 20:31:44 -08:00
dicedtomato
db3c5f48a5 Merge commit from fork
* fix: passkey impl

* fix: passkey impl

* fix: passkey impl + other stuff

* fix: cookies

* fix: passkey auth w/ cookie

* fix: cookie options
2026-01-08 23:26:16 -08:00
diced
cdcaa926fe fix: use gcm 2026-01-06 15:20:01 -08:00
dicedtomato
01503968ab Merge commit from fork 2026-01-06 15:11:43 -08:00
dicedtomato
8aa5ec6917 Merge commit from fork 2026-01-06 14:51:43 -08:00
dicedtomato
9befcaaf80 Merge commit from fork 2026-01-06 14:41:27 -08:00
dicedtomato
bfc0e4d40c Merge commit from fork 2026-01-06 14:30:41 -08:00
diced
4fb21f678e fix: add debugs for event emitter warnings 2026-01-02 23:26:54 -08:00
diced
f49598c760 fix: #950 2026-01-02 21:46:15 -08:00
diced
bfd6a8769d fix: #948 and tags/folders
fixes inconsistencies when editing other user's files
- tags menu shows their tags
- folders menu shows their folders
by design, you can't and will not be able to add another user's file to
your own folder.
this also introduces a few wip stuff, might be buggy, please bear with
me!
2025-12-31 00:03:55 -08:00
diced
87cf4916a5 fix: #949 2025-12-30 23:23:04 -08:00
diced
12ea806f0a fix: #945 2025-12-30 23:15:16 -08:00
diced
6269b457d8 fix: don't clamp lines on /view 2025-12-26 16:15:23 -08:00
diced
78f5875464 fix: omit meta tags when embed is disable 2025-12-26 15:55:05 -08:00
diced
05df685bd1 fix: better max/default expiration validation 2025-12-26 15:52:31 -08:00
diced
eaf245a4c9 fix: compression-type errors when no compression-percent 2025-12-26 15:43:00 -08:00
Zarox28
8a7b401b6e feat: add maximum expiration value (#934)
* feat: add maximum expiration value

* fix(settings)

* fix: add missing migration

* Update src/lib/import/version3/validateExport.ts

* fix: x-zipline-deletes-at with maxExpiration config

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2025-12-18 01:50:34 -08:00
diced
bb13e44bc9 fix: PWA and favicons #938 2025-12-17 19:54:23 -08:00
diced
2c21e119c4 fix: #926 2025-12-17 00:06:55 -08:00
diced
1585287b63 feat(v4.4.0): version 2025-12-13 14:37:48 -08:00
dicedtomato
1d4c3f26b4 Merge commit from fork 2025-12-13 14:31:55 -08:00
diced
589f06b460 feat: new actions page + finish impl v4 export 2025-12-08 23:30:04 -08:00
diced
ca09b1319d chore: update packages + eslint + lint 2025-12-08 01:29:12 -08:00
diced
5d27c14b77 feat: import v4 jsons (settings wip) 2025-12-08 01:07:15 -08:00
diced
9da74054ff fix: #931 2025-12-07 21:58:56 -08:00
diced
7572f7f3da fix: #935 2025-12-07 20:36:12 -08:00
diced
ef979d8853 feat: import v4 details (wip still) 2025-12-06 21:56:32 -08:00
diced
d090ed2cc1 fix: #926 for good 2025-12-06 20:37:55 -08:00
diced
3fc8b044bb fix: #926 animated compression removes animation 2025-12-05 19:56:05 -08:00
diced
61af46f136 feat: export and import v4 (wip) (needs testing) 2025-11-19 00:22:51 -08:00
diced
771aa67673 fix: editing files that are owned by the current user again 2025-11-18 20:37:51 -08:00
diced
b2db0c15a3 fix: editing files that are owned by current user 2025-11-15 23:20:11 -08:00
diced
d49afe60c8 fix: #924 2025-11-14 23:52:10 -08:00
diced
3370d4b663 fix: remove random logs 2025-11-14 23:50:35 -08:00
diced
1f1bcd3a47 feat: export folder as zip file 2025-11-14 23:48:50 -08:00
diced
d9df04bac5 fix: transactions not working for current user 2025-11-14 23:36:03 -08:00
diced
2bf2809269 fix: metrics erroring with null usernames 2025-11-14 23:18:01 -08:00
diced
9bb9e7e399 feat: add copy raw file link button to file modal 2025-11-14 23:08:05 -08:00
diced
89d6b2908d fix: change memory monitor to csv-like 2025-11-11 22:17:46 -08:00
diced
63c268cd1e fix: actually write new buffer to file (gps removal) 2025-11-07 22:06:29 -08:00
diced
6e2da52f77 feat: actions when viewing other user files (#918) 2025-11-03 16:37:12 -08:00
diced
04b27a2dee fix: build error 2025-11-03 15:40:15 -08:00
diced
6f4c3271c1 fix: #914 2025-11-03 15:36:09 -08:00
diced
b014f10240 fix: #916 2025-11-03 15:36:03 -08:00
diced
d3a417aff0 fix: #921 2025-11-03 15:24:14 -08:00
diced
63596d983e fix: #919 2025-10-28 12:10:06 -07:00
diced
ffbad41994 fix: export issues (#915) 2025-10-27 15:05:01 -07:00
diced
2a6f1f418a feat: log memory usage with DEBUG_MEMORY_LOG 2025-10-27 15:01:19 -07:00
diced
2402c6f0ef fix: performance issues with code renderer (#911) 2025-10-23 21:51:37 -07:00
diced
317e97e3a6 fix: show original name in view route #908 2025-10-19 21:27:06 -07:00
Venipa
f7753ccf2e fix: partial s3 upload ignoring subdirectory (#910, #909) 2025-10-18 20:56:59 -07:00
diced
2ad10e9a52 feat(v4.3.2): version 2025-10-16 21:12:40 -07:00
diced
b4be96c7a8 feat: support separate db vars + file version 2025-10-16 21:02:17 -07:00
diced
69dfad201b feat: reorder/disable/enable table fields in file table 2025-10-12 21:43:50 -07:00
diced
ee1681497e feat: allow any env to be read from a file 2025-10-12 21:43:34 -07:00
diced
2f19140085 feat: add file name in upload response 2025-10-03 21:01:18 -07:00
diced
c9d492f9d2 feat: trust proxies option (#879) 2025-10-03 20:55:35 -07:00
diced
a7a23f3fd9 chore: downgrade aws sdks (#888)
newer AWS sdks introduce dumb AWS specific stuff that break
interoperability with other services.
2025-09-19 20:26:20 -07:00
diced
36ffb669b2 fix: accidental force push lmaoo (#886)
PR: #886
2025-09-18 12:41:22 -07:00
diced
f0ee4cdab3 fix: allow any host on dev 2025-09-18 12:31:59 -07:00
diced
ac41dab2b2 fix: title not updating on first-load 2025-09-09 16:19:54 -07:00
diced
26661f7a83 fix: encode id for view route 2025-09-09 16:06:27 -07:00
diced
01a73df7f3 fix: say "try again" for invites when ratelimited 2025-09-08 23:08:29 -07:00
diced
6b1304f37b fix: #885 2025-09-08 23:06:27 -07:00
diced
19fc87818c feat(v4.3.1): version 2025-09-08 15:23:54 -07:00
diced
f168fa676d fix: better dev scripts runner 2025-09-08 11:53:45 -07:00
diced
44cb10acf2 fix: scripts 2025-09-08 11:50:45 -07:00
diced
2c21101e9e fix: remove log 2025-09-08 11:04:54 -07:00
diced
ecb83d96e3 fix: add /r/:id redirect (#882) 2025-09-08 10:35:21 -07:00
diced
bfae105e5f fix: invites not working 2025-09-06 16:29:24 -07:00
diced
3240e19710 fix: bypass local login #878 2025-09-06 12:51:46 -07:00
diced
40c12ca3f0 fix: 🖕prisma (rollback to working stuff) 2025-09-06 12:37:32 -07:00
diced
4907f4e450 fix: #876 2025-09-05 20:59:22 -07:00
diced
e2e3edd208 feat(v4.3.0): version 2025-09-05 11:30:53 -07:00
diced
b6abfe1ca7 fix: handle thumbnails properly in raw api routes 2025-09-05 11:24:58 -07:00
diced
ac61964c37 fix: new view counting method 2025-09-05 00:23:14 -07:00
diced
1924c22e1b feat: better max-views handling (#874) 2025-09-04 22:53:20 -07:00
diced
c15bf27b8a fix: config path conversion 2025-09-03 11:59:43 -07:00
diced
da8edb9c5d fix: prisma migrate 2025-09-03 11:49:13 -07:00
diced
c5ecd6fe64 fix: once and for all fix dockerfile 2025-09-03 00:12:48 -07:00
diced
0e0738f2fe fix: add scripts to dockerfile 2025-09-03 00:07:38 -07:00
diced
97b8483eeb fix: remove skip build 2025-09-03 00:04:27 -07:00
diced
3f0306e436 fix: remove extra steps 2025-09-03 00:03:08 -07:00
diced
87650d0fec feat: new scripts system 2025-09-03 00:00:04 -07:00
diced
0a59298fa0 chore: update to zod@4 2025-09-02 23:38:23 -07:00
diced
8e778d4178 fix: user not being included on text files (#871) 2025-09-02 16:18:22 -07:00
diced
a92f072d62 fix: password being reset when editing urls (#872) 2025-09-02 15:53:56 -07:00
diced
003dba9ce4 fix: show more information on client errors 2025-09-02 15:53:22 -07:00
diced
fd8d4fbe5e fix: don't allow deselecting in selects 2025-08-28 11:58:08 -07:00
diced
ac37f13452 feat: thumbnails output format (jpg, png, webp) 2025-08-27 21:18:46 -07:00
diced
ef13ef755c feat: default image compression type 2025-08-27 17:26:19 -07:00
diced
fdb0312dbe feat: compression formats 2025-08-27 16:42:36 -07:00
diced
95042e1383 fix: silently error out when no git sha #864 2025-08-25 15:03:43 -07:00
diced
f75020b115 fix: metrics admin only (#863) 2025-08-25 14:36:49 -07:00
diced
24ad601e2a fix: date normalization in ssr 2025-08-23 12:18:50 -07:00
diced
771811b4b7 chore: update packages 2025-08-21 15:03:26 -07:00
diced
459f99d507 feat: pdf rendering in dashboard
uses builtin browser renderer, basically every modern browser will work
2025-08-20 20:51:41 -07:00
diced
6758fe1037 feat: asciinema in dashboard rendering 2025-08-20 20:40:24 -07:00
diced
b48e9ba1e4 fix: reject partials on normal upload 2025-08-20 15:57:25 -07:00
diced
a9c7d694eb fix: z-index for dropzone 2025-08-19 15:25:31 -07:00
diced
18c428532f fix: use public endpoint for domains 2025-08-19 15:09:29 -07:00
diced
6acbf00b9e fix: linting 2025-08-18 12:39:25 -07:00
diced
471a060df2 fix: faulty domains code + errorboundary 2025-08-18 12:38:44 -07:00
diced
9cfb01cd88 fix: bug template error 2025-08-18 11:56:01 -07:00
diced
6442f5f3dc fix: new bug template 2025-08-18 11:53:19 -07:00
diced
c43afc1145 feat: extra css property for themes
allows adding extra css to custom themes, useful for loading fonts, etc.
2025-08-16 14:46:28 -07:00
diced
8a5972c517 fix: ishare icon 2025-08-14 16:56:24 -07:00
diced
f6eefc01e2 fix: build stage order 2025-08-14 12:34:21 -07:00
dicedtomato
ae7b4dacf1 feat: remove next.js in favor of client-side only (#857)
* feat: start removing next.js

* feat: working ssr + dev + prod env

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

* fix: build process

* fix: caching on pnpm action

* fix: ignores + cache action

* fix: docker + exdev error

* fix: generate prisma before types

* fix: remove node@20 from actions

* feat: dynamic import optimizations + titled pages

* fix: removed unused vars

* feat: small ui fixes and improvements

* feat: small ui improvements

* fix: linting error

* fix: regex when adding domains
2025-08-14 12:13:54 -07:00
317 changed files with 14189 additions and 8579 deletions

View File

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

View File

@@ -1,54 +1,71 @@
name: Bug
description: File a bug report
title: 'Bug: [insert title]'
name: Bug Report
description: Report a reproducible bug in Zipline
title: 'Bug: [short description of the issue]'
labels: ['bug']
body:
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Provide steps to reproduce the bug, and some context.
value: 'A bug happened!'
label: Bug description
description: |
Describe in detail what you were doing and what happened.
Please include screenshots, logs, or error messages if possible, as they help diagnose the issue faster.
validations:
required: true
- type: dropdown
id: version
id: runtime-type
attributes:
label: Version
description: What version (or docker image) of Zipline are you using?
label: How is Zipline being run?
description:
options:
- Latest v4 release (ghcr.io/diced/zipline or ghcr.io/diced/zipline:latest)
- Latest v4 commit (ghcr.io/diced/zipline:trunk)
- Latest v3 release (ghcr.io/diced/zipline:v3)
- Latest v3 commit (ghcr.io/diced/zipline:v3-trunk)
- other (provide version in additional info)
- On docker (docker, docker compose, etc.)
- Built from source (running it through `pnpm start` or `node`, etc.)
- Other (please specify in the "Zipline Version" section)
validations:
required: true
- type: textarea
id: runtime-version
attributes:
label: Zipline Version
description: |
Provide the version of Zipline you are using:
- If version checking is enabled (it is by default): paste the response from `http://<domain>/api/version`
- If using docker (and can't do the above): specify the tag you are using (`latest`, `trunk`, or a tag digest)
- A simple version number (e.g. "4.2.1") may also suffice
placeholder: "4.2.1"
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: What browser(s) are you seeing the problem on?
label: If applicable, what browsers are you seeing this issue on?
multiple: true
options:
- Firefox
- Chromium-based (Chrome, Edge, Brave, Opera, mobile chrome/chromium based, etc)
- Safari
- Firefox Mobile
- Safari Mobile
- Chromium based (Chrome, Brave, Edge, Opera, etc.)
- Firefox based (Firefox, Zen Browser, Waterfox, etc.)
- Safari (On macOS and/or iOS)
- Chromium based on Android/iOS
- Firefox based on Android/iOS
- Other (Please specify in the "Steps to Reproduce" section)
- type: textarea
id: zipline-logs
attributes:
label: Zipline Logs
description: Please copy and paste any relevant log output. Not seeing anything interesting? Try adding the `DEBUG=zipline` (v4) or `DEBUG=true` (v3) environment variable to see more logs, make sure to review the output and remove any sensitive information as it can be VERY verbose at times.
render: shell
label: Relevant Logs
description: |
Paste any relevant logs from Zipline or the browser (if applicable).
If logs don't look useful, you can enable debug mode by setting the environment variable `DEBUG=zipline` when starting Zipline.
Then reproduce the issue and copy the logs here.
**Note:** Debug logs may contain sensitive information.
- type: textarea
id: browser-logs
id: reproduction
attributes:
label: Browser Logs
description: Please copy and paste any relevant log output.
render: shell
- type: textarea
id: additional-info
attributes:
label: Additional Info
description: Anything else that could be used to narrow down the issue, like your config.
label: Steps to Reproduce
description: |
Please list the exact steps required to reproduce the issue.
Include any relevant configuration options, settings, or external services that may affect Ziplines functionality.

View File

@@ -3,9 +3,9 @@ contact_links:
- name: Feature Request
url: https://github.com/diced/zipline/discussions/new?category=ideas&title=Your%20brief%20description%20here&labels=feature
about: Ask for a new feature
- name: Documentation
url: https://zipline.diced.sh
about: Maybe take a look a the docs?
- name: Zipline Discord
url: https://discord.gg/EAhCRfGxCF
about: Ask for help with anything related to Zipline!
- name: Zipline Docs
url: https://zipline.diced.sh
about: Maybe take a look a the docs?

View File

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

100
.github/workflows/gen-openapi.yml vendored Normal file
View File

@@ -0,0 +1,100 @@
name: Generate OpenAPI Spec
on:
push:
branches: [v4, trunk]
pull_request:
branches: [v4, trunk]
workflow_dispatch:
jobs:
gen-openapi:
strategy:
matrix:
node: [24.x]
arch: [amd64]
runs-on: ubuntu-24.04
services:
postgres:
image: postgres:16
ports:
- 5432:5432
env:
POSTGRES_USER: zipline
POSTGRES_PASSWORD: zipline
POSTGRES_DB: zipline
options: >-
--health-cmd="pg_isready -U zipline -d zipline"
--health-interval=5s
--health-timeout=5s
--health-retries=10
steps:
- uses: actions/checkout@v4
- name: Use node@${{ matrix.node }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- uses: pnpm/action-setup@v4
with:
run_install: false
- name: Get pnpm store directory
shell: bash
id: pnpm-cache
run: |
echo "store_path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: |
${{ steps.pnpm-cache.outputs.store_path }}
key: ${{ runner.os }}-${{ matrix.arch }}-${{ matrix.node }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-${{ matrix.arch }}-${{ matrix.node }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}-
- name: Install
run: pnpm install
- name: Build
env:
ZIPLINE_BUILD: 'true'
run: pnpm build
- name: Generate secret
id: secret
run: |
SECRET=$(openssl rand -base64 48 | tr -dc 'a-zA-Z0-9')
echo "secret=$SECRET" >> $GITHUB_OUTPUT
- name: Wait for Postgres
run: |
until pg_isready -h localhost -p 5432 -U zipline; do
echo "Waiting for postgres..."
sleep 2
done
- name: Run app
env:
DATABASE_URL: postgres://zipline:zipline@localhost:5432/zipline
CORE_SECRET: ${{ steps.secret.outputs.secret }}
ZIPLINE_OUTPUT_OPENAPI: true
run: pnpm start
- name: Verify openapi.json exists
run: |
if [ ! -f "./openapi.json" ]; then
echo "openapi.json not found"
exit 1
fi
- name: Upload openapi.json
uses: actions/upload-artifact@v4
with:
name: openapi-json
path: ./openapi.json

12
.gitignore vendored
View File

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

View File

@@ -20,14 +20,20 @@ FROM base AS builder
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
COPY src ./src
COPY next.config.js ./next.config.js
COPY .gitignore ./.gitignore
COPY postcss.config.cjs ./postcss.config.cjs
COPY prettier.config.cjs ./prettier.config.cjs
COPY eslint.config.mjs ./eslint.config.mjs
COPY vite.config.ts ./vite.config.ts
COPY tsup.config.ts ./tsup.config.ts
COPY tsconfig.json ./tsconfig.json
COPY mimes.json ./mimes.json
COPY code.json ./code.json
COPY vite-env.d.ts ./vite-env.d.ts
COPY scripts ./scripts
ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN ZIPLINE_BUILD=true pnpm run build
@@ -36,14 +42,11 @@ FROM base
COPY --from=deps /zipline/node_modules ./node_modules
COPY --from=builder /zipline/build ./build
COPY --from=builder /zipline/.next ./.next
COPY --from=builder /zipline/mimes.json ./mimes.json
COPY --from=builder /zipline/code.json ./code.json
COPY --from=builder /zipline/generated ./generated
RUN pnpm build:prisma
RUN pnpm prisma generate
# clean
RUN rm -rf /tmp/* /root/*

View File

@@ -3,16 +3,14 @@
The next generation ShareX / File upload server
![Stars](https://img.shields.io/github/stars/diced/zipline?logo=github&style=flat)
![Version](https://img.shields.io/github/package-json/v/diced/zipline?logo=git&logoColor=white&style=flat)
![GitHub last commit (branch)](https://img.shields.io/github/last-commit/diced/zipline/trunk?logo=git&logoColor=white&style=flat)
[![Discord](https://img.shields.io/discord/729771078196527176?color=%23777ed3&label=discord&logo=discord&logoColor=white&style=flat)](https://discord.gg/EAhCRfGxCF)
![Stars](https://img.shields.io/github/stars/diced/zipline?logo=github&style=for-the-badge)
![Version](https://img.shields.io/github/package-json/v/diced/zipline?logo=git&logoColor=white&style=for-the-badge)
![GitHub last commit (branch)](https://img.shields.io/github/last-commit/diced/zipline/trunk?logo=git&logoColor=white&style=for-the-badge)
[![Discord](https://img.shields.io/discord/729771078196527176?color=%23777ed3&label=discord&logo=discord&logoColor=white&style=for-the-badge)](https://discord.gg/EAhCRfGxCF)
![Build](https://img.shields.io/github/actions/workflow/status/diced/zipline/build.yml?logo=github&style=flat&branch=trunk)
![Build](https://img.shields.io/github/actions/workflow/status/diced/zipline/build.yml?logo=github&style=for-the-badge&branch=trunk)
[zipline.diced.sh](https://zipline.diced.sh) | [old v3.zipline.diced.sh](https://v3.zipline.diced.sh)
<!-- TODO: change these links and image branch -->
Documentation: [zipline.diced.sh](https://zipline.diced.sh)
</div>
@@ -200,13 +198,13 @@ 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:
If you have [Nix](https://nixos.org) and [direnv](https://direnv.net/) installed, you can simply cd into the cloned directory and run the following command:
```bash
direnv allow
```
Granted that you have direnv setup properly, you will now be in a new nix shell with all the dependencies and PostgreSQL installed.
After doing so, your shell will be setup for development.
If you aren't using direnv, you can run the following command to enter the nix shell:
@@ -216,11 +214,12 @@ 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 |
| Command | Description |
| --------------- | --------------------------------------------- |
| `pgup` | Starts the postgres server in the background. |
| `pg_ctl status` | See if the postgres server is running |
| `minioup` | Start a Minio server for testing S3 |
| `downall` | Stops any running postgres or minio service. |
After familiarizing yourself with the environment, you can continue below (skipping the prerequisites since they are already installed).

View File

@@ -1,6 +1,6 @@
services:
postgres:
image: postgres:15
image: postgres:16
restart: unless-stopped
environment:
- POSTGRES_USER=postgres

View File

@@ -6,7 +6,7 @@ services:
- .env
environment:
POSTGRES_USER: ${POSTGRESQL_USER:-zipline}
POSTGRES_PASSWORD: ${POSTGRESQL_PASSWORD:?POSTGRESSQL_PASSWORD is required}
POSTGRES_PASSWORD: ${POSTGRESQL_PASSWORD:?POSTGRESQL_PASSWORD is required}
POSTGRES_DB: ${POSTGRESQL_DB:-zipline}
volumes:
- pgdata:/var/lib/postgresql/data

View File

@@ -1,15 +1,17 @@
import unusedImports from 'eslint-plugin-unused-imports';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-plugin-prettier';
import prettierConfig from 'eslint-config-prettier';
import nextConfig from '@next/eslint-plugin-next';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import reactPlugin from 'eslint-plugin-react';
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
import tseslint from 'typescript-eslint';
import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
import prettier from 'eslint-plugin-prettier';
import prettierConfig from 'eslint-config-prettier';
import unusedImports from 'eslint-plugin-unused-imports';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -20,64 +22,53 @@ const gitignorePatterns = gitignoreContent
.filter((line) => line.trim() && !line.startsWith('#'))
.map((pattern) => pattern.trim());
export default tseslint.config(
{ ignores: gitignorePatterns },
import { defineConfig } from 'eslint/config';
...tseslint.configs.recommended,
export default defineConfig(
tseslint.configs.recommended,
jsxA11yPlugin.flatConfigs.recommended,
reactPlugin.configs.flat.recommended,
reactHooksPlugin.configs.flat.recommended,
reactRefreshPlugin.configs.vite,
{ ignores: gitignorePatterns },
{
files: ['**/*.{js,mjs,cjs,ts,tsx}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaFeatures: { jsx: true },
},
},
plugins: {
'unused-imports': unusedImports,
prettier: prettier,
'@next/next': nextConfig,
'react-hooks': reactHooksPlugin,
react: reactPlugin,
'jsx-a11y': jsxA11yPlugin,
'react-hooks': reactHooksPlugin,
prettier,
'unused-imports': unusedImports,
},
rules: {
...reactPlugin.configs.recommended.rules,
...reactHooksPlugin.configs.recommended.rules,
...nextConfig.configs.recommended.rules,
...nextConfig.configs['core-web-vitals'].rules,
...prettierConfig.rules,
'prettier/prettier': [
'error',
{},
{
fileInfoOptions: {
withNodeModules: false,
},
ignoreFileExtensions: ['pnpm-lock.yaml'],
},
],
'prettier/prettier': ['error', {}, { fileInfoOptions: { withNodeModules: false } }],
'linebreak-style': ['error', 'unix'],
quotes: [
'error',
'single',
{
avoidEscape: true,
},
],
quotes: ['error', 'single', { 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-hooks/set-state-in-effect': 'warn',
'react-refresh/only-export-components': 'off',
'react/jsx-uses-react': 'warn',
'react/jsx-uses-vars': 'warn',
'react/no-danger-with-children': 'warn',
@@ -88,31 +79,29 @@ export default tseslint.config(
'react/react-in-jsx-scope': 'off',
'react/require-render-return': 'error',
'react/style-prop-object': 'warn',
'jsx-a11y/alt-text': 'off',
'react/display-name': 'off',
'jsx-a11y/alt-text': 'off',
'jsx-a11y/no-autofocus': 'off',
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/no-static-element-interactions': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
{ vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' },
],
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
},
settings: {
react: {
version: 'detect',
},
next: {
rootDir: __dirname,
},
react: { version: 'detect' },
},
},
);

View File

@@ -122,6 +122,7 @@
["calx", ["application/vnd.ms-office.calx"]],
["cap", ["application/vnd.tcpdump.pcap"]],
["car", ["application/vnd.curl.car"]],
["cast", ["application/x-asciicast"]],
["cat", ["application/vnd.ms-pki.seccat"]],
["cb7", ["application/x-cbr"]],
["cba", ["application/x-cbr"]],

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,21 +2,16 @@
"name": "zipline",
"private": true,
"license": "MIT",
"version": "4.2.3",
"version": "4.4.1",
"scripts": {
"build": "cross-env pnpm run --stream \"/^build:.*/\"",
"build:prisma": "prisma generate --no-hints",
"build:next": "ZIPLINE_BUILD=true next build",
"build:server": "tsup",
"dev": "cross-env TURBOPACK=1 NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:nd": "cross-env TURBOPACK=1 NODE_ENV=development tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:inspector": "cross-env TURBOPACK=1 NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
"start": "cross-env NODE_ENV=production node --trace-warnings --require dotenv/config --enable-source-maps ./build/server",
"build": "tsx scripts/build.ts",
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:nd": "cross-env NODE_ENV=development tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:inspector": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
"start": "cross-env NODE_ENV=production node --trace-warnings --require dotenv/config ./build/server",
"start:inspector": "cross-env NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
"ctl": "NODE_ENV=production node --require dotenv/config --enable-source-maps ./build/ctl",
"validate": "pnpm run --stream \"/^validate:.*/\"",
"validate:lint": "eslint --cache --fix .",
"validate:format": "prettier --write --ignore-path .gitignore .",
"validate": "tsx scripts/validate.ts",
"db:prototype": "prisma db push --skip-generate && prisma generate --no-hints",
"db:migrate": "prisma migrate dev --create-only",
"docker:engine": "colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w",
@@ -26,93 +21,105 @@
"docker:compose:dev:logs": "docker-compose --file docker-compose.dev.yml logs -f"
},
"dependencies": {
"@aws-sdk/client-s3": "3.859.0",
"@aws-sdk/lib-storage": "3.859.0",
"@aws-sdk/client-s3": "3.726.1",
"@aws-sdk/lib-storage": "3.726.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.1.0",
"@fastify/multipart": "^9.0.3",
"@fastify/multipart": "^9.3.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.2.0",
"@github/webauthn-json": "^2.1.1",
"@mantine/charts": "^8.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",
"@fastify/sensible": "^6.0.4",
"@fastify/static": "^8.3.0",
"@fastify/swagger": "^9.6.1",
"@mantine/charts": "^8.3.9",
"@mantine/code-highlight": "^8.3.9",
"@mantine/core": "^8.3.9",
"@mantine/dates": "^8.3.9",
"@mantine/dropzone": "^8.3.9",
"@mantine/form": "^8.3.9",
"@mantine/hooks": "^8.3.9",
"@mantine/modals": "^8.3.9",
"@mantine/notifications": "^8.3.9",
"@prisma/adapter-pg": "6.13.0",
"@prisma/client": "6.13.0",
"@prisma/engines": "6.13.0",
"@prisma/internals": "6.13.0",
"@prisma/migrate": "6.13.0",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@smithy/node-http-handler": "^4.1.1",
"@tabler/icons-react": "^3.35.0",
"archiver": "^7.0.1",
"argon2": "^0.44.0",
"asciinema-player": "^3.12.1",
"bytes": "^3.1.2",
"clsx": "^2.1.1",
"colorette": "^2.0.20",
"commander": "^14.0.0",
"cross-env": "^10.0.0",
"dayjs": "^1.11.13",
"dotenv": "^17.2.1",
"commander": "^14.0.2",
"cookie": "^1.1.1",
"cross-env": "^10.1.0",
"dayjs": "^1.11.19",
"dotenv": "^17.2.3",
"fast-glob": "^3.3.3",
"fastify": "^5.4.0",
"fastify-plugin": "^5.0.1",
"fflate": "^0.8.2",
"fastify": "^5.6.2",
"fastify-plugin": "^5.1.0",
"fastify-type-provider-zod": "^6.1.0",
"fluent-ffmpeg": "^2.1.3",
"highlight.js": "^11.11.1",
"iron-session": "^8.0.4",
"isomorphic-dompurify": "^2.26.0",
"katex": "^0.16.22",
"mantine-datatable": "^8.2.0",
"isomorphic-dompurify": "^2.33.0",
"katex": "^0.16.27",
"mantine-datatable": "^8.3.9",
"ms": "^2.1.3",
"multer": "2.0.2",
"next": "^15.4.5",
"nuqs": "^2.4.3",
"otplib": "^12.0.1",
"prisma": "^6.13.0",
"prisma": "6.13.0",
"qrcode": "^1.5.4",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.10.1",
"react-window": "1.8.11",
"remark-gfm": "^4.0.1",
"sharp": "^0.34.3",
"swr": "^2.3.4",
"typescript-eslint": "^8.38.0",
"zod": "^3.25.67",
"zustand": "^5.0.7"
"sharp": "^0.34.5",
"swr": "^2.3.7",
"typescript-eslint": "^8.48.1",
"vite": "^7.2.7",
"zod": "^4.1.13",
"zustand": "^5.0.9"
},
"devDependencies": {
"@next/eslint-plugin-next": "^15.4.5",
"@types/archiver": "^7.0.0",
"@types/bytes": "^3.1.5",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/katex": "^0.16.7",
"@types/ms": "^2.1.0",
"@types/multer": "^2.0.0",
"@types/node": "^24.1.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"eslint": "^9.32.0",
"eslint-config-next": "^15.4.5",
"@types/node": "^24.10.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-unused-imports": "^4.1.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"eslint-plugin-unused-imports": "^4.3.0",
"postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.6.2",
"sass": "^1.89.2",
"prettier": "^3.7.4",
"sass": "^1.94.2",
"tsc-alias": "^1.8.16",
"tsup": "^8.5.0",
"tsx": "^4.20.3",
"typescript": "^5.9.2"
"tsup": "^8.5.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=22"

6177
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "filesDefaultCompressionFormat" TEXT DEFAULT 'jpg';

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "featuresThumbnailsFormat" TEXT NOT NULL DEFAULT 'jpg';

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "coreTrustProxy" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "filesMaxExpiration" TEXT;

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- You are about to drop the column `mfaPasskeys` on the `Zipline` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "public"."Zipline" DROP COLUMN "mfaPasskeys",
ADD COLUMN "mfaPasskeysEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "mfaPasskeysOrigin" TEXT,
ADD COLUMN "mfaPasskeysRpID" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "tasksCleanThumbnailsInterval" TEXT NOT NULL DEFAULT '1d';

View File

@@ -1,6 +1,7 @@
generator client {
provider = "prisma-client-js"
output = "../generated/client"
provider = "prisma-client"
output = "../src/prisma"
moduleFormat = "cjs"
previewFeatures = ["queryCompiler", "driverAdapters"]
}
@@ -19,6 +20,7 @@ model Zipline {
coreReturnHttpsUrls Boolean @default(false)
coreDefaultDomain String?
coreTempDirectory String // default join(tmpdir(), 'zipline')
coreTrustProxy Boolean @default(false)
chunksEnabled Boolean @default(true)
chunksMax String @default("95mb")
@@ -29,18 +31,21 @@ model Zipline {
tasksMaxViewsInterval String @default("30m")
tasksThumbnailsInterval String @default("30m")
tasksMetricsInterval String @default("30m")
tasksCleanThumbnailsInterval String @default("1d")
filesRoute String @default("/u")
filesLength Int @default(6)
filesDefaultFormat String @default("random")
filesDisabledExtensions String[]
filesMaxFileSize String @default("100mb")
filesDefaultExpiration String?
filesAssumeMimetypes Boolean @default(false)
filesDefaultDateFormat String @default("YYYY-MM-DD_HH:mm:ss")
filesRemoveGpsMetadata Boolean @default(false)
filesRandomWordsNumAdjectives Int @default(2)
filesRandomWordsSeparator String @default("-")
filesRoute String @default("/u")
filesLength Int @default(6)
filesDefaultFormat String @default("random")
filesDisabledExtensions String[]
filesMaxFileSize String @default("100mb")
filesDefaultExpiration String?
filesMaxExpiration String?
filesAssumeMimetypes Boolean @default(false)
filesDefaultDateFormat String @default("YYYY-MM-DD_HH:mm:ss")
filesRemoveGpsMetadata Boolean @default(false)
filesRandomWordsNumAdjectives Int @default(2)
filesRandomWordsSeparator String @default("-")
filesDefaultCompressionFormat String? @default("jpg")
urlsRoute String @default("/go")
urlsLength Int @default(6)
@@ -54,13 +59,14 @@ model Zipline {
featuresThumbnailsEnabled Boolean @default(true)
featuresThumbnailsNumberThreads Int @default(4)
featuresThumbnailsFormat String @default("jpg")
featuresMetricsEnabled Boolean @default(true)
featuresMetricsAdminOnly Boolean @default(false)
featuresMetricsShowUserSpecific Boolean @default(true)
featuresVersionChecking Boolean @default(true)
featuresVersionAPI String @default("https://zipline-version.diced.sh")
featuresVersionAPI String @default("https://zipline-version.diced.sh")
invitesEnabled Boolean @default(true)
invitesLength Int @default(6)
@@ -103,7 +109,10 @@ model Zipline {
mfaTotpEnabled Boolean @default(false)
mfaTotpIssuer String @default("Zipline")
mfaPasskeys Boolean @default(false)
mfaPasskeysEnabled Boolean @default(false)
mfaPasskeysRpID String?
mfaPasskeysOrigin String?
ratelimitEnabled Boolean @default(true)
ratelimitMax Int @default(10)
@@ -137,7 +146,7 @@ model Zipline {
pwaThemeColor String @default("#000000")
pwaBackgroundColor String @default("#000000")
domains String[] @default([])
domains String[] @default([])
}
model User {
@@ -291,8 +300,8 @@ model Folder {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
public Boolean @default(false)
name String
public Boolean @default(false)
allowUploads Boolean @default(false)
files File[]

24
scripts/build.ts Normal file
View File

@@ -0,0 +1,24 @@
import { run, step } from '.';
import { lintStep } from './lint';
run(
'build',
lintStep,
step('prisma', 'prisma generate'),
step('typecheck', 'tsc', () => !process.argv.includes('--skip')),
// builds
step('server', 'tsup'),
// client stuff
step('client', 'vite build'),
step(
'client/ssr/view',
'vite build --ssr ssr-view/server.tsx -m ssr-view --outDir ../../build/ssr --emptyOutDir=false',
),
step(
'client/ssr/view-url',
'vite build --ssr ssr-view-url/server.tsx -m ssr-view-url --outDir ../../build/ssr --emptyOutDir=false',
),
);

49
scripts/index.ts Normal file
View File

@@ -0,0 +1,49 @@
export function step(name: string, command: string, condition: () => boolean = () => true) {
return {
name,
command,
condition,
};
}
export type Step = ReturnType<typeof step>;
function log(message: string) {
console.log(`\n${message}\n`);
}
export async function run(name: string, ...steps: Step[]) {
const { execSync } = await import('child_process');
const runOne = process.argv[2];
if (runOne) {
const match = steps.find((s) => `${name}/${s.name}` === runOne);
if (!match) {
console.error(`x No step found with name "${runOne}"`);
process.exit(1);
}
steps = [match];
}
const start = process.hrtime();
for (const step of steps) {
if (!step.condition()) {
log(`- Skipping step "${name}/${step.name}"...`);
continue;
}
try {
log(`> Running step "${name}/${step.name}"...`);
execSync(step.command, { stdio: 'inherit' });
} catch {
console.error(`x Step "${name}/${step.name}" failed.`);
process.exit(1);
}
}
const diff = process.hrtime(start);
const time = diff[0] * 1e9 + diff[1];
const timeStr = time > 1e9 ? `${(time / 1e9).toFixed(2)}s` : `${(time / 1e6).toFixed(2)}ms`;
log(`✓ Steps in "${name}" completed in ${timeStr}.`);
}

3
scripts/lint.ts Normal file
View File

@@ -0,0 +1,3 @@
import { step } from '.';
export const lintStep = step('lint', 'eslint .');

9
scripts/validate.ts Normal file
View File

@@ -0,0 +1,9 @@
import { run, step } from '.';
import { lintStep } from './lint';
run(
'validate',
lintStep,
step('format', 'prettier --write --ignore-path .gitignore .'),
);

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

@@ -0,0 +1,69 @@
import { ContextModalProps, 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';
import { Button, Text } from '@mantine/core';
const AlertModal = ({ context, id, innerProps }: ContextModalProps<{ modalBody: string }>) => (
<>
<Text size='sm'>{innerProps.modalBody}</Text>
<Button fullWidth mt='md' onClick={() => context.closeModal(id)}>
OK
</Button>
</>
);
const contextModals = {
alert: AlertModal,
};
declare module '@mantine/modals' {
export interface MantineModalsOverride {
modals: typeof contextModals;
}
}
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,
}}
modals={contextModals}
>
<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, type: 'dashboard' }}
/>
);
}

View File

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

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, type: 'root' }}
/>
);
}

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

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="manifest" href="manifest.json">
<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,8 +1,11 @@
import { useTitle } from '@/lib/hooks/useTitle';
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() {
useTitle('404');
return (
<Center h='100vh'>
<Stack>
@@ -11,12 +14,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 { authenticateWeb } from '@/lib/passkey';
import { eitherTrue } from '@/lib/primitive';
import useLogin from '@/lib/hooks/useLogin';
import { useTitle } from '@/lib/hooks/useTitle';
import {
Button,
Center,
@@ -24,6 +21,7 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications, showNotification } from '@mantine/notifications';
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
import {
IconBrandDiscordFilled,
IconBrandGithubFilled,
@@ -34,28 +32,42 @@ import {
IconUserPlus,
IconX,
} from '@tabler/icons-react';
import { InferGetServerSidePropsType } from 'next';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import GenericError from '../../error/GenericError';
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: '',
@@ -80,6 +86,9 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
username: (value) => (value.length > 1 ? null : 'Username is required'),
password: (value) => (value.length > 1 ? null : 'Password is required'),
},
enhanceGetInputProps: ({ field }) => ({
name: field,
}),
});
const onSubmit = async (values: typeof form.values, code: string | undefined = undefined) => {
@@ -122,10 +131,24 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
const handlePasskeyLogin = async () => {
try {
setPasskeyLoading(true);
const res = await authenticateWeb();
const { data: options, error: optionsError } = await fetchApi<Response['/api/auth/webauthn/options']>(
'/api/auth/webauthn/options',
'GET',
);
if (optionsError) {
setPasskeyErrored(true);
setPasskeyLoading(false);
notifications.show({
title: 'Error while authenticating with passkey',
message: optionsError.error,
color: 'red',
});
return;
}
const res = await startAuthentication({ optionsJSON: options!.options! });
const { data, error } = await fetchApi<Response['/api/auth/webauthn']>('/api/auth/webauthn', 'POST', {
auth: res.toJSON(),
response: res,
});
if (error) {
setPasskeyErrored(true);
@@ -146,16 +169,22 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
};
useEffect(() => {
if (willRedirect) {
if (user) {
navigate('/dashboard');
}
}, [user]);
useEffect(() => {
if (willRedirect && config) {
const provider = Object.keys(config.oauthEnabled).find(
(x) => config.oauthEnabled[x as keyof SafeConfig['oauthEnabled']] === true,
(x) => config.oauthEnabled[x as keyof typeof config.oauthEnabled] === true,
);
if (provider) {
router.push(`/api/auth/oauth/${provider}`);
window.location.href = `/api/auth/oauth/${provider.toLowerCase()}`;
}
}
}, []);
}, [willRedirect, config]);
useEffect(() => {
if (passkeyErrored) {
@@ -172,6 +201,23 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
}
}, [passkeyErrored]);
useEffect(() => {
if (config?.firstSetup) navigate('/auth/setup');
}, [config]);
if (configLoading) return <LoadingOverlay visible />;
if (configError)
return (
<GenericError
title='Error loading configuration'
message='Could not load server configuration...'
details={configError}
/>
);
if (!config) return <LoadingOverlay visible />;
return (
<>
{willRedirect && !showLocalLogin && <LoadingOverlay visible />}
@@ -255,7 +301,10 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
ta='center'
style={{
whiteSpace: 'normal',
fontSize: `clamp(20px, ${Math.max(50 - (config.website.title?.length ?? 0) / 2, 20)}px, 50px)`,
fontSize: `clamp(20px, ${Math.max(
50 - (config.website.title?.length ?? 0) / 2,
20,
)}px, 50px)`,
}}
>
<b>{config.website.title ?? 'Zipline'}</b>
@@ -263,51 +312,51 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
</div>
{showLocalLogin && (
<>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<Stack my='sm'>
<TextInput
size='md'
placeholder='Enter your username...'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
},
}}
{...form.getInputProps('username', { withError: true })}
/>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<Stack my='sm'>
<TextInput
size='md'
placeholder='Enter your username...'
autoComplete='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...'
autoComplete='current-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' />
)}
{config.mfa.passkeys && (
{config.mfa.passkeys && browserSupportsWebAuthn() && (
<Button
onClick={handlePasskeyLogin}
size='md'
@@ -324,7 +373,7 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
{config.features.userRegistration && (
<Button
component={Link}
href='/auth/register'
to='/auth/register'
size='md'
fullWidth
variant='outline'
@@ -333,6 +382,7 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
Sign up
</Button>
)}
<Group grow>
{config.oauthEnabled.discord && (
<ExternalAuthButton
@@ -359,19 +409,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,286 @@
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { useTitle } from '@/lib/hooks/useTitle';
import {
Button,
Center,
Checkbox,
Divider,
Image,
LoadingOverlay,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications, showNotification } from '@mantine/notifications';
import { IconLogin, IconPlus, IconUserPlus, IconX } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import useSWR, { mutate } from 'swr';
import GenericError from '../../error/GenericError';
export function Component() {
useTitle('Register');
const location = useLocation();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const {
data: config,
error: configError,
isLoading: configLoading,
} = useSWR<Response['/api/server/public']>('/api/server/public', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
});
const code = new URLSearchParams(location.search).get('code') ?? undefined;
const {
data: invite,
error: inviteError,
isLoading: inviteLoading,
} = useSWR<Response['/api/auth/invites/web']>(
location.search.includes('code') ? `/api/auth/invites/web${location.search}` : null,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
},
);
const form = useForm({
initialValues: {
username: '',
password: '',
tos: false,
},
validate: {
username: (value) => (value.length < 1 ? 'Username is required' : null),
password: (value) => (value.length < 1 ? 'Password is required' : null),
},
enhanceGetInputProps: ({ field }) => ({
name: field,
}),
});
useEffect(() => {
(async () => {
const res = await fetch('/api/user');
if (res.ok) {
navigate('/dashboard');
} else {
setLoading(false);
}
})();
}, []);
useEffect(() => {
if (!config) return;
if (!config?.features.userRegistration && !code) {
navigate('/auth/login');
}
}, [code, config]);
const onSubmit = async (values: typeof form.values) => {
const { username, password, tos } = values;
if (tos === false && config!.website.tos) {
form.setFieldError('tos', 'You must agree to the Terms of Service to continue');
return;
}
const { data, error } = await fetchApi('/api/auth/register', 'POST', {
username,
password,
code,
});
if (error) {
if (error.error === 'Username is taken') {
form.setFieldError('username', 'Username is taken');
} else {
notifications.show({
title: 'Failed to register',
message: error.error,
color: 'red',
icon: <IconX size='1rem' />,
});
}
} else {
notifications.show({
title: 'Complete!',
message: `Your "${data?.user?.username}" account has been created.`,
color: 'green',
icon: <IconPlus size='1rem' />,
});
mutate('/api/user');
navigate('/dashboard');
}
};
if (loading || configLoading) return <LoadingOverlay visible />;
if (!config || configError) {
return (
<GenericError
title='Error loading configuration'
message='Could not load server configuration...'
details={configError}
/>
);
}
if (code && inviteError) {
if (inviteError) {
showNotification({
id: 'invalid-invite',
message: 'Invalid or expired invite. Please try again later.',
color: 'red',
});
navigate('/auth/login');
return null;
}
if (inviteLoading) return <LoadingOverlay visible />;
}
return (
<Center h='100vh'>
{config.website.loginBackground && (
<Image
src={config.website.loginBackground}
alt='Background'
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
...(config.website.loginBackgroundBlur && { filter: 'blur(10px)' }),
}}
/>
)}
<Paper
w='350px'
p='xl'
shadow='xl'
withBorder
style={{
backgroundColor: config.website.loginBackground ? 'rgba(0, 0, 0, 0)' : undefined,
backdropFilter: config.website.loginBackgroundBlur ? 'blur(35px)' : undefined,
}}
>
<div style={{ width: '100%', overflowWrap: 'break-word' }}>
<Title
order={1}
ta='center'
style={{
whiteSpace: 'normal',
fontSize: `clamp(20px, ${Math.max(50 - (config.website.title?.length ?? 0) / 2, 20)}px, 50px)`,
}}
>
<b>{config.website.title ?? 'Zipline'}</b>
</Title>
</div>
{invite && (
<Text ta='center' size='sm' c='dimmed'>
Youve been invited to join <b>{config?.website?.title ?? 'Zipline'}</b>
{invite.inviter && (
<>
{' '}
by <b>{invite.inviter.username}</b>
</>
)}
</Text>
)}
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack my='sm'>
<TextInput
size='md'
placeholder='Enter your username...'
autoComplete='username'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
},
}}
{...form.getInputProps('username', { withError: true })}
/>
<PasswordInput
size='md'
placeholder='Enter your password...'
autoComplete='new-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';

63
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));
@@ -54,6 +65,9 @@ export default function Setup() {
username: (value) => (value.length < 1 ? 'Username is required' : null),
password: (value) => (value.length < 1 ? 'Password is required' : null),
},
enhanceGetInputProps: ({ field }) => ({
name: field,
}),
});
const onSubmit = async (values: typeof form.values) => {
@@ -99,18 +113,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 +154,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>
@@ -170,12 +183,14 @@ export default function Setup() {
<TextInput
label='Username'
placeholder='Enter a username...'
autoComplete='username'
{...form.getInputProps('username')}
/>
<PasswordInput
label='Password'
placeholder='Enter a password...'
autoComplete='new-password'
{...form.getInputProps('password')}
/>
</Stack>
@@ -236,20 +251,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 DashboardServerActions from '@/components/pages/serverActions';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Server Actions');
return <DashboardServerActions />;
}
Component.displayName = 'Dashboard/Admin/Actions';

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,28 @@
import DashboardMetrics from '@/components/pages/metrics';
import { useTitle } from '@/lib/hooks/useTitle';
import { isAdministrator } from '@/lib/role';
import { redirect } from 'react-router-dom';
export async function loader() {
const configRes = await fetch('/api/server/public');
if (!configRes.ok) throw new Error('Failed to get public configuration');
const config = await configRes.json();
if (config.features.metrics?.adminOnly) {
const res = await fetch('/api/user');
if (!res.ok) return redirect('/auth/login');
const { user } = await res.json();
if (!isAdministrator(user.role)) return redirect('/dashboard');
}
return {};
}
export function Component() {
useTitle('Metrics');
return <DashboardMetrics />;
}
Component.displayName = 'Dashboard/Metrics';

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,60 @@
import { type Response } from '@/lib/api/response';
import { useTitle } from '@/lib/hooks/useTitle';
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>();
useTitle(folder.name ?? '');
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,59 @@
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 { useTitle } from '@/lib/hooks/useTitle';
import { Anchor, Center, Container, Text } from '@mantine/core';
import { data, Link, Params, useLoaderData } from 'react-router-dom';
import useSWR from 'swr';
export async function loader({ params }: { params: Params<string> }) {
const res = await fetch(`/api/server/folder/${params.id}?upload=true`);
if (!res.ok) {
throw data('Folder not found', { status: 404 });
}
return {
folder: (await res.json()) as Response['/api/server/folder/[id]'],
};
}
export function Component() {
const { folder } = useLoaderData<typeof loader>();
const { data: config } = useSWR<Response['/api/server/public']>('/api/server/public', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
});
useTitle(`Upload to ${folder.name ?? 'folder'}`);
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,256 @@
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';
import { useTitle } from '@/lib/hooks/useTitle';
type SsrData = {
file: Partial<NonNullable<Awaited<ReturnType<typeof getFile>>>>;
password?: boolean;
code: boolean;
user?: Partial<User>;
host: string;
pw?: string | null;
metrics?: Awaited<ReturnType<typeof parserMetrics>>;
filesRoute?: string;
};
export default function ViewFileId() {
const data = useSsrData<SsrData>();
if (!data) return null;
const { file, password, code, user, host, metrics, filesRoute, pw } = data;
// Fix dates that were stringified during SSR
if (file?.createdAt) (file as any).createdAt = new Date(file.createdAt);
if (file?.updatedAt) (file as any).updatedAt = new Date(file.updatedAt);
if (file?.deletesAt) (file as any).deletesAt = new Date(file.deletesAt);
if (user?.createdAt) (user as any).createdAt = new Date(user.createdAt);
if (user?.updatedAt) (user as any).updatedAt = new Date(user.updatedAt);
const [passwordValue, setPassword] = useState<string>('');
const [passwordError, setPasswordError] = useState<string>('');
const [detailsOpen, setDetailsOpen] = useState<boolean>(false);
useTitle(file.name ?? 'View File');
return password && !pw ? (
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
<form
onSubmit={async (e) => {
e.preventDefault();
const res = await fetch(`/api/user/files/${file.id}/password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: passwordValue.trim() }),
});
if (res.ok) {
window.location.reload();
} else {
setPasswordError('Invalid password');
}
}}
>
<PasswordInput
description='This file is password protected, enter password to view it'
required
mb='sm'
value={passwordValue}
onChange={(event) => setPassword(event.currentTarget.value)}
error={passwordError}
/>
<Button
fullWidth
variant='outline'
my='sm'
type='submit'
disabled={passwordValue.trim().length === 0}
>
Verify
</Button>
</form>
</Modal>
) : code ? (
<>
<Paper withBorder style={{ borderTop: 0, borderLeft: 0, borderRight: 0 }}>
<Group justify='space-between' py={5} px='xs'>
<Text c='dimmed'>{file.name}</Text>
<Group>
<ActionIcon size='md' variant='outline' onClick={() => setDetailsOpen((o) => !o)}>
<IconInfoCircleFilled size='1rem' />
</ActionIcon>
<ActionIcon
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}?download=true${pw ? `&pw=${pw}` : ''}`}
target='_blank'
>
<IconDownload size='1rem' />
</ActionIcon>
</Group>
</Group>
</Paper>
<Collapse in={detailsOpen}>
<Paper m='md' p='md' withBorder>
{user?.view!.content && (
<Typography>
<Text
ta={user?.view!.align ?? 'left'}
dangerouslySetInnerHTML={{
__html: sanitize.sanitize(
parseString(user.view.content, {
file: file as unknown as File,
user: user as User,
link: {
returned: `${host}${formatRootUrl(filesRoute ?? '/u', file.name!)}`,
raw: `${host}/raw/${file.name}`,
},
...metrics,
}) ?? '',
{
USE_PROFILES: { html: true },
FORBID_TAGS: ['style', 'script'],
},
),
}}
/>
</Typography>
)}
</Paper>
</Collapse>
{file.name!.endsWith('.md') || file.name!.endsWith('.tex') ? (
<Paper m='md' p='md' withBorder>
<DashboardFileType file={file as unknown as File} password={pw} show code={code} />
</Paper>
) : (
<Box m='sm'>
<DashboardFileType file={file as unknown as File} password={pw} show code={code} />
</Box>
)}
</>
) : (
<>
<Center h='100%'>
<Paper m='md' p='md' shadow='md' radius='md' withBorder>
<Group justify='space-between' mb='sm'>
<Group>
<Text size='lg' fw={700} display='flex'>
{file.name}{' '}
</Text>
{user?.view!.showTags && (
<Group gap={4}>
{file.tags?.map((tag) => (
<TagPill key={tag.id} tag={tag} />
))}
</Group>
)}
{user?.view!.showFolder &&
file.Folder &&
(file.Folder.public ? (
<Tooltip label='View folder'>
<Anchor component={Link} ml='sm' to={`/folder/${file.Folder.id}`}>
{file.Folder.name}
</Anchor>
</Tooltip>
) : (
<Text ml='sm' size='sm' c='dimmed'>
{file.Folder.name}
</Text>
))}
{user?.view!.showMimetype && (
<Text size='sm' c='dimmed' ml='sm' style={{ alignSelf: 'center' }}>
{file.type}
</Text>
)}
</Group>
<ActionIcon.Group>
<Tooltip label='View raw file'>
<ActionIcon
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}${pw ? `?pw=${pw}` : ''}`}
target='_blank'
>
<IconExternalLink size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Download file'>
<ActionIcon
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}?download=true${pw ? `&pw=${pw}` : ''}`}
target='_blank'
>
<IconDownload size='1rem' />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</Group>
<DashboardFileType allowZoom file={file as unknown as File} password={pw} show />
{user?.view!.content && (
<Typography>
<Text
mt='sm'
ta={user?.view.align ?? 'left'}
dangerouslySetInnerHTML={{
__html: sanitize.sanitize(
parseString(user?.view.content, {
file: file as unknown as File,
link: {
returned: `${host}${formatRootUrl(filesRoute ?? '/u', file.name!)}`,
raw: `${host}/raw/${file.name}`,
},
user: user as User,
...metrics,
}) ?? '',
{
USE_PROFILES: { html: true },
FORBID_TAGS: ['script'],
},
),
}}
/>
</Typography>
)}
</Paper>
</Center>
</>
);
}

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

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

@@ -0,0 +1,119 @@
import Layout from '@/components/Layout';
import { Response as ApiResponse } from '@/lib/api/response';
import { isAdministrator } from '@/lib/role';
import { createBrowserRouter, data, redirect } from 'react-router-dom';
import DashboardErrorBoundary from './error/DashboardErrorBoundary';
import RootErrorBoundary from './error/RootErrorBoundary';
import FourOhFour from './pages/404';
import Login from './pages/auth/login';
import Logout from './pages/auth/logout';
import Root from './Root';
export async function dashboardLoader() {
try {
const res = await fetch('/api/server/settings/web');
if (!res.ok) {
return redirect('/auth/login');
}
const data = await res.json();
console.log('Loaded settings:', data);
return data as ApiResponse['/api/server/settings/web'];
} catch (error) {
throw data('Failed to load settings' + (error as any).message, { status: 500 });
}
}
export const router = createBrowserRouter([
{
Component: Root,
path: '/',
children: [
{
ErrorBoundary: RootErrorBoundary,
children: [
{ path: '*', Component: FourOhFour },
{
path: '/auth',
children: [
{ path: 'login', Component: Login },
{ path: 'logout', Component: Logout },
{ path: 'register', lazy: () => import('./pages/auth/register') },
{
path: 'setup',
lazy: () => import('./pages/auth/setup'),
},
{ path: 'tos', lazy: () => import('./pages/auth/tos') },
],
},
{
path: '/dashboard',
Component: Layout,
loader: dashboardLoader,
children: [
{
ErrorBoundary: DashboardErrorBoundary,
children: [
{ index: true, lazy: () => import('./pages/dashboard/index') },
{ path: 'metrics', lazy: () => import('./pages/dashboard/metrics') },
{ path: 'settings', lazy: () => import('./pages/dashboard/settings') },
{ path: 'files', lazy: () => import('./pages/dashboard/files') },
{ path: 'folders', lazy: () => import('./pages/dashboard/folders') },
{ path: 'urls', lazy: () => import('./pages/dashboard/urls') },
{
path: 'upload',
children: [
{ path: 'file', lazy: () => import('./pages/dashboard/upload/file') },
{ path: 'text', lazy: () => import('./pages/dashboard/upload/text') },
],
},
{
path: 'admin',
loader: async () => {
const res = await fetch('/api/user');
if (!res.ok) {
return redirect('/auth/login');
}
const { user } = await res.json();
if (!isAdministrator(user.role)) return redirect('/dashboard');
},
children: [
{ path: 'invites', lazy: () => import('./pages/dashboard/admin/invites') },
{ path: 'settings', lazy: () => import('./pages/dashboard/admin/settings') },
{ path: 'actions', lazy: () => import('./pages/dashboard/admin/actions') },
{
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,275 @@
import '@mantine/charts/styles.css';
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/dropzone/styles.css';
import '@mantine/notifications/styles.css';
import 'mantine-datatable/styles.css';
import { isCode } from '@/lib/code';
import { config as zConfig } from '@/lib/config';
import type { Config } from '@/lib/config/validate';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { File, fileSelect } from '@/lib/db/models/file';
import { User, userSelect } from '@/lib/db/models/user';
import { parseString } from '@/lib/parser';
import { parserMetrics } from '@/lib/parser/metrics';
import { createZiplineSsr } from '@/lib/ssr/createZiplineSsr';
import type { ZiplineTheme } from '@/lib/theme';
import { readThemes } from '@/lib/theme/file';
import * as cookie from 'cookie';
import { FastifyRequest } from 'fastify';
import { renderToString } from 'react-dom/server';
import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router-dom';
import { createRoutes } from './routes';
export const getFile = async (id: string) =>
prisma.file.findFirst({
where: { name: decodeURIComponent(id) },
select: {
...fileSelect,
password: true,
userId: true,
thumbnail: { select: { path: true } },
tags: { select: { id: true, name: true, color: true } },
Folder: { select: { id: true, public: true, name: true } },
},
});
export async function render(
{
defaultTheme,
req,
}: {
themes: ZiplineTheme[];
defaultTheme: Config['website']['theme'];
req: FastifyRequest;
},
url: string,
) {
const id = url.split('/').pop();
if (!id) return { html: 'Not Found', meta: '', status: 404 };
const { config: libConfig, reloadSettings } = await import('@/lib/config');
if (!libConfig) await reloadSettings();
const file = await getFile(id);
if (!file || !file.userId) return { html: 'Not Found', meta: '', status: 404 };
if (file.maxViews && file.views >= file.maxViews) return { html: 'Gone', meta: '', status: 410 };
if (file.deletesAt && file.deletesAt <= new Date()) return { html: 'Expired', meta: '', status: 410 };
const user = await prisma.user.findFirst({
where: { id: file.userId },
select: {
...userSelect,
oauthProviders: false,
passkeys: false,
sessions: false,
totpSecret: false,
quota: false,
},
});
if (!user) return { html: 'Not Found', meta: '', status: 404 };
let host = req.headers.host || 'localhost';
const proto = req.headers['x-forwarded-proto'];
try {
if (
JSON.parse(req.headers['cf-visitor'] as string)?.scheme === 'https' ||
proto === 'https' ||
zConfig.core.returnHttpsUrls
) {
host = `https://${host}`;
} else {
host = `http://${host}`;
}
} catch {
host = proto === 'https' || zConfig.core.returnHttpsUrls ? `https://${host}` : `http://${host}`;
}
const code = await isCode(file.name);
const themes = await readThemes();
const metrics = await parserMetrics(user.id);
const config = { website: { theme: zConfig.website.theme } };
const cookies = cookie.parse(req.headers.cookie || '');
const pw = cookies[`file_pw_${file.id}`];
const hasPassword = !!file.password;
if (hasPassword) {
if (pw) {
const verified = await verifyPassword(pw, file.password!);
if (!verified) return { html: 'Forbidden', meta: '', status: 403 };
delete (file as any).password;
} else {
delete (file as any).password;
const data = {
file: { id: file.id, name: file.name, type: file.type },
password: true,
code,
user,
host,
themes,
metrics,
config,
};
const routes = createRoutes(themes, defaultTheme);
const { query } = createStaticHandler(routes);
const context = await query(
new Request('http://client' + url, {
method: 'GET',
headers: new Headers({ accept: 'text/html' }),
}),
);
if (context instanceof Response) {
return context;
}
const router = createStaticRouter(routes, context);
const html = renderToString(<StaticRouterProvider context={context} router={router} />);
return {
html,
meta: `<title>Password Protected</title>\n${createZiplineSsr(data)}`,
};
}
}
const data = {
file,
password: hasPassword,
pw: pw || null,
code,
user,
host,
themes,
metrics,
filesRoute: zConfig.files.route,
config,
};
const routes = createRoutes(themes, defaultTheme);
const { query } = createStaticHandler(routes);
const context = await query(
new Request('http://client' + url, {
method: 'GET',
headers: new Headers({ accept: 'text/html' }),
}),
);
if (context instanceof Response) {
return context;
}
const router = createStaticRouter(routes, context);
const html = renderToString(<StaticRouterProvider context={context} router={router} />);
const meta = `
${
user?.view?.embedTitle && user.view.embed
? `<meta property="og:title" content="${
parseString(user.view.embedTitle, {
file: file as unknown as File,
user: user as User,
...metrics,
}) ?? ''
}" />`
: ''
}
${
user?.view?.embedDescription && user.view.embed
? `<meta property="og:description" content="${
parseString(user.view.embedDescription, {
file: file as unknown as File,
user: user as User,
...metrics,
}) ?? ''
}" />`
: ''
}
${
user?.view?.embedSiteName && user.view.embed
? `<meta property="og:site_name" content="${
parseString(user.view.embedSiteName, {
file: file as unknown as File,
user: user as User,
...metrics,
}) ?? ''
}" />`
: ''
}
${
user?.view?.embedColor && user.view.embed
? `<meta property="theme-color" content="${
parseString(user.view.embedColor, {
file: file as unknown as File,
user: user as User,
...metrics,
}) ?? ''
}" />`
: ''
}
${
file.type?.startsWith('image')
? `
<meta property="og:type" content="image" />
<meta property="og:image" itemProp="image" content="${host}/raw/${file.name}" />
<meta property="og:url" content="${host}/raw/${file.name}" />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:image" content="${host}/raw/${file.name}" />
<meta property="twitter:title" content="${file.name}" />
`
: ''
}
${
file.type?.startsWith('video')
? `
${file.thumbnail ? `<meta property="og:image" content="${host}/raw/${file.thumbnail.path}" />` : ''}
<meta property="og:type" content="video.other" />
<meta property="og:video:url" content="${host}/raw/${file.name}" />
<meta property="og:video:width" content="1920" />
<meta property="og:video:height" content="1080" />
`
: ''
}
${
file.type?.startsWith('audio')
? `
<meta name="twitter:card" content="player" />
<meta name="twitter:player" content="${host}/raw/${file.name}" />
<meta name="twitter:player:stream" content="${host}/raw/${file.name}" />
<meta name="twitter:player:stream:content_type" content="${file.type}" />
<meta name="twitter:title" content="${file.name}" />
<meta name="twitter:player:width" content="720" />
<meta name="twitter:player:height" content="480" />
<meta property="og:type" content="music.song" />
<meta property="og:url" content="${host}/raw/${file.name}" />
<meta property="og:audio" content="${host}/raw/${file.name}" />
<meta property="og:audio:secure_url" content="${host}/raw/${file.name}" />
<meta property="og:audio:type" content="${file.type}" />
`
: ''
}
${
!file.type?.startsWith('video') && !file.type?.startsWith('image')
? `
<meta property="og:url" content="${host}/raw/${file.name}" />
`
: ''
}
<title>${file.originalName ?? file.name}</title>
`;
return {
html,
meta: `${user.view.embed ? 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 {
@@ -40,15 +41,16 @@ import {
IconRefreshDot,
IconSettingsFilled,
IconShieldLockFilled,
IconStopwatch,
IconTags,
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;
@@ -125,6 +127,12 @@ const navLinks: NavLinks[] = [
if: (user) => user?.role === 'SUPERADMIN',
href: '/dashboard/admin/settings',
},
{
label: 'Actions',
icon: <IconStopwatch size='1rem' />,
active: (path: string) => path === '/dashboard/admin/actions',
href: '/dashboard/admin/actions',
},
{
label: 'Users',
icon: <IconUsersGroup size='1rem' />,
@@ -142,14 +150,17 @@ const navLinks: NavLinks[] = [
},
];
export default function Layout({ children, config }: { children: React.ReactNode; config: SafeConfig }) {
export default function Layout() {
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const [opened, setOpened] = useState(false);
const router = useRouter();
const modals = useModals();
const clipboard = useClipboard();
const setUser = useUserStore((s) => s.setUser);
const location = useLocation();
const loaderData = useLoaderData<typeof dashboardLoader>();
const config = loaderData.config;
const { user, mutate } = useLogin();
const { avatar } = useAvatar();
@@ -275,7 +286,8 @@ export default function Layout({ children, config }: { children: React.ReactNode
<Menu.Item
leftSection={<IconSettingsFilled size='1rem' />}
component={Link}
href='/dashboard/settings'
to='/dashboard/settings'
prefetch='intent'
>
Settings
</Menu.Item>
@@ -284,7 +296,8 @@ export default function Layout({ children, config }: { children: React.ReactNode
<Menu.Item
leftSection={<IconAdjustments size='1rem' />}
component={Link}
href='/dashboard/admin/settings'
to='/dashboard/admin/settings'
prefetch='intent'
>
Server Settings
</Menu.Item>
@@ -295,7 +308,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
color='red'
leftSection={<IconLogout size='1rem' />}
component={Link}
href='/auth/logout'
to='/auth/logout'
>
Logout
</Menu.Item>
@@ -322,9 +335,10 @@ export default function Layout({ children, config }: { children: React.ReactNode
leftSection={link.icon}
variant='light'
rightSection={<IconChevronRight size='0.7rem' />}
active={router.pathname === link.href}
active={location.pathname === link.href}
component={Link}
href={link.href || ''}
to={link.href || ''}
prefetch='intent'
/>
);
} else {
@@ -335,7 +349,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
leftSection={link.icon}
variant='light'
rightSection={<IconChevronRight size='0.7rem' />}
defaultOpened={link.active(router.pathname)}
defaultOpened={link.active(location.pathname)}
>
{link.links
.filter(
@@ -348,9 +362,10 @@ export default function Layout({ children, config }: { children: React.ReactNode
leftSection={sublink.icon}
rightSection={<IconChevronRight size='0.7rem' />}
variant='light'
active={router.pathname === sublink.href}
active={location.pathname === sublink.href}
component={Link}
href={sublink.href || ''}
to={sublink.href || ''}
prefetch='intent'
/>
))}
</NavLink>
@@ -372,7 +387,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
leftSection={<IconExternalLink size='1rem' />}
variant='light'
component={Link}
href={url}
to={url}
target='_blank'
/>
))}
@@ -382,9 +397,9 @@ export default function Layout({ children, config }: { children: React.ReactNode
</AppShell.Navbar>
<AppShell.Main>
<ConfigProvider config={config}>
<ConfigProvider data={loaderData}>
<Paper m='lg' withBorder p='xs'>
{children}
<Outlet />
</Paper>
</ConfigProvider>
</AppShell.Main>

View File

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

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

@@ -29,6 +29,7 @@ import { showNotification } from '@mantine/notifications';
import {
Icon,
IconBombFilled,
IconClipboardTypography,
IconCopy,
IconDeviceSdCard,
IconDownload,
@@ -46,7 +47,7 @@ import {
IconTrashFilled,
IconUpload,
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import useSWR, { mutate } from 'swr';
import DashboardFileType from '../DashboardFileType';
import {
@@ -88,11 +89,13 @@ export default function FileModal({
setOpen,
file,
reduce,
user,
}: {
open: boolean;
setOpen: (open: boolean) => void;
file?: File | null;
reduce?: boolean;
user?: string;
}) {
const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
@@ -100,7 +103,7 @@ export default function FileModal({
const [editFileOpen, setEditFileOpen] = useState(false);
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
'/api/user/folders?noincl=true',
'/api/user/folders?noincl=true' + (user ? `&user=${user}` : ''),
);
const folderCombobox = useCombobox();
@@ -114,10 +117,14 @@ export default function FileModal({
}
};
const { data: tags } = useSWR<Extract<Response['/api/user/tags'], Tag[]>>('/api/user/tags');
const { data: tags } = useSWR<Extract<Response['/api/user/tags'], Tag[]>>(
user ? `/api/users/${user}/tags` : '/api/user/tags',
);
const tagsCombobox = useCombobox();
const [value, setValue] = useState(file?.tags?.map((x) => x.id) ?? []);
const [value, setValue] = useState<string[]>(() => file?.tags?.map((x) => x.id) ?? []);
const handleValueSelect = (val: string) => {
setValue((current) => (current.includes(val) ? current.filter((v) => v !== val) : [...current, val]));
};
@@ -167,14 +174,6 @@ export default function FileModal({
const values = value.map((tag) => <TagPill key={tag} tag={tags?.find((t) => t.id === tag) || null} />);
useEffect(() => {
if (file) {
setValue(file.tags?.map((x) => x.id) ?? []);
} else {
setValue([]);
}
}, [file]);
return (
<>
<EditFileDetailsModal open={editFileOpen} onClose={() => setEditFileOpen(false)} file={file!} />
@@ -234,15 +233,15 @@ export default function FileModal({
</Title>
<Combobox
zIndex={90000}
withinPortal={false}
store={tagsCombobox}
onOptionSubmit={handleValueSelect}
withinPortal={false}
>
<Combobox.DropdownTarget>
<PillsInput
onBlur={() => triggerSave()}
pointer
onClick={() => tagsCombobox.toggleDropdown()}
onClick={() => tagsCombobox.openDropdown()}
>
<Pill.Group>
{values.length > 0 ? (
@@ -254,9 +253,14 @@ export default function FileModal({
<Combobox.EventsTarget>
<PillsInput.Field
type='hidden'
onFocus={() => tagsCombobox.openDropdown()}
onBlur={() => tagsCombobox.closeDropdown()}
onKeyDown={(event) => {
if (event.key === 'Backspace') {
if (
event.key === 'Backspace' &&
value.length > 0 &&
event.currentTarget.value === ''
) {
event.preventDefault();
handleValueRemove(value[value.length - 1]);
}
@@ -285,9 +289,7 @@ export default function FileModal({
</Combobox.Option>
))
) : (
<Combobox.Option value='no-tags' disabled>
No tags found, create one outside of this menu.
</Combobox.Option>
<Combobox.Empty>No tags found, create one outside of this menu.</Combobox.Empty>
)}
</Combobox.Options>
</Combobox.Dropdown>
@@ -310,8 +312,8 @@ export default function FileModal({
</Button>
) : (
<Combobox
store={folderCombobox}
withinPortal={false}
store={folderCombobox}
onOptionSubmit={(value) => handleAdd(value)}
>
<Combobox.Target>
@@ -398,6 +400,11 @@ export default function FileModal({
tooltip='View file in a new tab'
color='blue'
/>
<ActionButton
Icon={IconClipboardTypography}
onClick={() => copyFile(file, clipboard, true)}
tooltip='Copy raw file link'
/>
<ActionButton
Icon={IconCopy}
onClick={() => copyFile(file, clipboard)}

View File

@@ -6,12 +6,12 @@ import FileModal from './FileModal';
import styles from './index.module.css';
export default function DashboardFile({ file, reduce }: { file: File; reduce?: boolean }) {
export default function DashboardFile({ file, reduce, id }: { file: File; reduce?: boolean; id?: string }) {
const [open, setOpen] = useState(false);
return (
<>
<FileModal open={open} setOpen={setOpen} file={file} reduce={reduce} />
<FileModal open={open} setOpen={setOpen} file={file} reduce={reduce} user={id} />
<Card shadow='md' radius='md' p={0} onClick={() => setOpen(true)} className={styles.file}>
<DashboardFileType key={file.id} file={file} />
</Card>

View File

@@ -11,11 +11,13 @@ import {
Text,
} from '@mantine/core';
import { Icon, IconFileUnknown, IconPlayerPlay, IconShieldLockFilled } from '@tabler/icons-react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { renderMode } from '../pages/upload/renderMode';
import Asciinema from '../render/Asciinema';
import Pdf from '../render/Pdf';
import Render from '../render/Render';
import fileIcon from './fileIcon';
import { parseAsStringLiteral, useQueryState } from 'nuqs';
import { useUserStore } from '@/lib/store/user';
function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
return (
@@ -78,15 +80,17 @@ export default function DashboardFileType({
code?: boolean;
allowZoom?: boolean;
}) {
const [overrideType] = useQueryState('otype', parseAsStringLiteral(['video', 'audio', 'image', 'text']));
const user = useUserStore((state) => state.user);
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
const fileRoute = user ? `/api/user/files/${(file as DbFile).id}/raw` : `/raw/${file.name}`;
const thumbnailRoute = user
? `/api/user/files/${(file as DbFile).thumbnail?.path}/raw`
: `/raw/${(file as DbFile).thumbnail?.path}`;
const dbFile = 'id' in file;
const renderIn = useMemo(() => renderMode(file.name.split('.').pop() || ''), [file.name]);
const [fileContent, setFileContent] = useState('');
const [type, setType] = useState<string>(file.type.split('/')[0]);
const [type, setType] = useState(file.type.split('/')[0]);
const [open, setOpen] = useState(false);
@@ -109,7 +113,7 @@ export default function DashboardFileType({
}
if (file.size > 1 * 1024 * 1024) {
const res = await fetch(`/raw/${file.name}${password ? `?pw=${password}` : ''}`, {
const res = await fetch(`${fileRoute}${password ? `?pw=${password}` : ''}`, {
headers: {
Range: 'bytes=0-' + 1 * 1024 * 1024, // 0 mb to 1 mb
},
@@ -122,7 +126,7 @@ export default function DashboardFileType({
return;
}
const res = await fetch(`/raw/${file.name}${password ? `?pw=${password}` : ''}`);
const res = await fetch(`${fileRoute}${password ? `?pw=${password}` : ''}`);
if (!res.ok) throw new Error('Failed to fetch file');
const text = await res.text();
setFileContent(text);
@@ -135,7 +139,7 @@ export default function DashboardFileType({
if (code) {
setType('text');
getText();
} else if (overrideType === 'text' || type === 'text') {
} else if (type === 'text') {
getText();
} else {
return;
@@ -167,23 +171,22 @@ export default function DashboardFileType({
</Paper>
);
switch (overrideType || type) {
case 'video':
const isAsciicast = file.type === 'application/x-asciicast' || file.name.endsWith('.cast');
switch (true) {
case type === 'video':
return show ? (
<video
width='100%'
autoPlay
muted
controls
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
style={{ cursor: 'pointer', maxWidth: '85vw', maxHeight: '85vh' }}
/>
) : (file as DbFile).thumbnail && dbFile ? (
<Box pos='relative'>
<MantineImage
src={`/raw/${(file as DbFile).thumbnail!.path}`}
alt={file.name || 'Video thumbnail'}
/>
<MantineImage src={thumbnailRoute} alt={file.name || 'Video thumbnail'} />
<Center
pos='absolute'
@@ -204,11 +207,12 @@ export default function DashboardFileType({
) : (
<Placeholder text={`Click to play video ${file.name}`} Icon={fileIcon(file.type)} />
);
case 'image':
case type === 'image':
return show ? (
<Center>
<MantineImage
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
alt={file.name || 'Image'}
style={{
cursor: allowZoom ? 'zoom-in' : 'default',
@@ -220,9 +224,7 @@ export default function DashboardFileType({
{allowZoom && open && (
<FileZoomModal setOpen={setOpen}>
<MantineImage
src={
dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)
}
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
alt={file.name || 'Image'}
style={{
maxWidth: '95vw',
@@ -239,23 +241,25 @@ export default function DashboardFileType({
<MantineImage
fit='contain'
mah={400}
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
alt={file.name || 'Image'}
/>
);
case 'audio':
case type === 'audio':
return show ? (
<audio
autoPlay
muted
controls
style={{ width: '100%' }}
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
/>
) : (
<Placeholder text={`Click to play audio ${file.name}`} Icon={fileIcon(file.type)} />
);
case 'text':
case type === 'text':
return show ? (
fileContent.trim() === '' ? (
<LoadingOverlay
@@ -279,6 +283,24 @@ export default function DashboardFileType({
) : (
<Placeholder text={`Click to view text ${file.name}`} Icon={fileIcon(file.type)} />
);
case isAsciicast === true:
return show && dbFile ? (
<Asciinema src={`${fileRoute}${password ? `?pw=${password}` : ''}`} />
) : (
<Placeholder
text={`Click to download asciinema cast ${file.name}`}
Icon={fileIcon('application/x-asciicast')}
/>
);
case file.type === 'application/pdf':
return show && dbFile ? (
<Pdf src={`${fileRoute}${password ? `?pw=${password}` : ''}`} />
) : (
<Placeholder text={`Click to view PDF ${file.name}`} Icon={fileIcon(file.type)} />
);
default:
if (dbFile && !show)
return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
@@ -287,7 +309,7 @@ export default function DashboardFileType({
return (
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
<Placeholder
onClick={() => window.open(`/raw/${file.name}${password ? `?pw=${password}` : ''}`)}
onClick={() => window.open(`${fileRoute}${password ? `?pw=${password}` : ''}`)}
text={`Click to view file ${file.name} in a new tab`}
Icon={fileIcon(file.type)}
/>

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) {
@@ -27,17 +27,21 @@ export function downloadFile(file: File) {
window.open(`/raw/${file.name}?download=true`, '_blank');
}
export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>) {
export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>, raw: boolean = false) {
const domain = `${window.location.protocol}//${window.location.host}`;
const url = file.url ? `${domain}${file.url}` : `${domain}/view/${file.name}`;
const url = raw
? `${domain}/raw/${file.name}`
: file.url
? `${domain}${file.url}`
: `${domain}/view/${file.name}`;
clipboard.copy(url);
notifications.show({
title: 'Copied link',
message: (
<Anchor component={Link} href={url}>
<Anchor component={Link} to={url}>
{url}
</Anchor>
),

View File

@@ -16,6 +16,7 @@ import {
IconFileTypeHtml,
IconFileTypeJs,
IconFileTypeJsx,
IconFileTypePdf,
IconFileTypePhp,
IconFileTypePpt,
IconFileTypeRs,
@@ -49,7 +50,7 @@ const icons: Record<string, Icon> = {
'application/x-gzip': IconFileZip,
// common text/document files that are not detected by the 'text' type
'application/pdf': IconFileText,
'application/pdf': IconFileTypePdf,
'application/msword': IconFileTypeDocx,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': IconFileTypeDocx,
'application/vnd.ms-excel': IconFileTypeXls,
@@ -67,6 +68,7 @@ const icons: Record<string, Icon> = {
'text/javascript': IconFileTypeJs,
'application/json': IconBracketsContain,
'text/xml': IconFileTypeXml,
'application/x-asciicast': IconTerminal2,
// zipline text uploads
'text/x-zipline-html': IconFileTypeHtml,

View File

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

View File

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

View File

@@ -0,0 +1,99 @@
import { FieldSettings, useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button, Checkbox, Group, Modal, Paper, Text } from '@mantine/core';
import { IconGripVertical } from '@tabler/icons-react';
import { useShallow } from 'zustand/shallow';
export const NAMES = {
name: 'Name',
originalName: 'Original Name',
tags: 'Tags',
type: 'Type',
size: 'Size',
createdAt: 'Created At',
favorite: 'Favorite',
views: 'Views',
};
function SortableTableField({ item }: { item: FieldSettings }) {
const setVisible = useFileTableSettingsStore((state) => state.setVisible);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: item.field,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
cursor: 'grab',
width: '100%',
};
return (
<Paper withBorder p='xs' ref={setNodeRef} style={style} {...attributes} {...listeners}>
<Group gap='xs'>
<IconGripVertical size='1rem' />
<Checkbox checked={item.visible} onChange={() => setVisible(item.field, !item.visible)} />
<Text>{NAMES[item.field]}</Text>
</Group>
</Paper>
);
}
export default function TableEditModal({ opened, onCLose }: { opened: boolean; onCLose: () => void }) {
const [fields, setIndex, reset] = useFileTableSettingsStore(
useShallow((state) => [state.fields, state.setIndex, state.reset]),
);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor),
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id) {
const newIndex = fields.findIndex((item) => item.field === over?.id);
setIndex(active.id as FieldSettings['field'], newIndex);
}
};
return (
<Modal opened={opened} onClose={onCLose} title='Table Options' centered>
<Text mb='md' size='sm' c='dimmed'>
Select and drag fields below to make them appear/disappear/reorder in the file table view.
</Text>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={fields.map((item) => item.field)} strategy={verticalListSortingStrategy}>
{fields.map((item, index) => (
<div
key={index}
style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}
>
<SortableTableField item={item} />
</div>
))}
</SortableContext>
</DndContext>
<Button fullWidth color='red' onClick={() => reset()} variant='light' mt='md'>
Reset to Default
</Button>
</Modal>
);
}

View File

@@ -69,20 +69,23 @@ export async function bulkDelete(ids: string[], setSelectedFiles: (files: File[]
});
}
export async function bulkFavorite(ids: string[]) {
export async function bulkFavorite(ids: string[], favorite: boolean) {
const text = favorite ? 'favorite' : 'unfavorite';
const textcaps = favorite ? 'Favorite' : 'Unfavorite';
modals.openConfirmModal({
centered: true,
title: `Favorite ${ids.length} file${ids.length === 1 ? '' : 's'}?`,
children: `You are about to favorite ${ids.length} file${ids.length === 1 ? '' : 's'}.`,
title: `${textcaps} ${ids.length} file${ids.length === 1 ? '' : 's'}?`,
children: `You are about to ${text} ${ids.length} file${ids.length === 1 ? '' : 's'}.`,
labels: {
cancel: 'Cancel',
confirm: 'Favorite',
confirm: `${textcaps}`,
},
confirmProps: { color: 'yellow' },
onConfirm: async () => {
notifications.show({
title: 'Favoriting files',
message: `Favoriting ${ids.length} file${ids.length === 1 ? '' : 's'}`,
title: `${textcaps}ing files`,
message: `${textcaps}ing ${ids.length} file${ids.length === 1 ? '' : 's'}`,
color: 'yellow',
loading: true,
id: 'bulk-favorite',
@@ -96,13 +99,13 @@ export async function bulkFavorite(ids: string[]) {
{
files: ids,
favorite: true,
favorite,
},
);
if (error) {
notifications.update({
title: 'Error while favoriting files',
title: 'Error while modifying files',
message: error.error,
color: 'red',
icon: <IconStarsOff size='1rem' />,
@@ -112,8 +115,8 @@ export async function bulkFavorite(ids: string[]) {
});
} else if (data) {
notifications.update({
title: 'Favorited files',
message: `Favorited ${data.count} file${ids.length === 1 ? '' : 's'}`,
title: `${textcaps}d files`,
message: `${textcaps}d ${data.count} file${ids.length === 1 ? '' : 's'}`,
color: 'yellow',
icon: <IconStarsFilled size='1rem' />,
id: 'bulk-favorite',

View File

@@ -6,19 +6,23 @@ 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 { IconFileUpload, IconGridPatternFilled, IconTableOptions } from '@tabler/icons-react';
import { Link } from 'react-router-dom';
import { useState } from 'react';
export default function DashbaordFiles() {
export default function DashboardFiles() {
const view = useViewStore((state) => state.files);
const [tableEditOpen, setTableEditOpen] = useState(false);
const [idSearchOpen, setIdSearchOpen] = useState(false);
return (
<>
<Group>
<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>
@@ -28,6 +32,27 @@ export default function DashbaordFiles() {
<TagsButton />
<PendingFilesButton />
{view === 'table' && (
<>
<Tooltip label='Table Options'>
<ActionIcon variant='outline' onClick={() => setTableEditOpen((open) => !open)}>
<IconTableOptions size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Search by ID'>
<ActionIcon
variant='outline'
onClick={() => {
setIdSearchOpen((open) => !open);
}}
>
<IconGridPatternFilled size='1rem' />
</ActionIcon>
</Tooltip>
</>
)}
<GridTableSwitcher type='files' />
</Group>
@@ -38,7 +63,16 @@ export default function DashbaordFiles() {
<Files />
</>
) : (
<FileTable />
<FileTable
idSearch={{
open: idSearchOpen,
setOpen: setIdSearchOpen,
}}
tableEdit={{
open: tableEditOpen,
setOpen: setTableEditOpen,
}}
/>
)}
</>
);

View File

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

View File

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

View File

@@ -1,11 +1,12 @@
import RelativeDate from '@/components/RelativeDate';
import FileModal from '@/components/file/DashboardFile/FileModal';
import { addMultipleToFolder, copyFile, deleteFile, downloadFile } from '@/components/file/actions';
import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import { type File } from '@/lib/db/models/file';
import { Folder } from '@/lib/db/models/folder';
import { Tag } from '@/lib/db/models/tag';
import { useQueryState } from '@/lib/hooks/useQueryState';
import { useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
import { useSettingsStore } from '@/lib/store/settings';
import {
ActionIcon,
@@ -33,19 +34,20 @@ import {
IconDownload,
IconExternalLink,
IconFile,
IconGridPatternFilled,
IconStar,
IconTrashFilled,
} from '@tabler/icons-react';
import { DataTable } from 'mantine-datatable';
import Link from 'next/link';
import { parseAsBoolean, parseAsInteger, parseAsStringLiteral, useQueryState } from 'nuqs';
import { useEffect, useReducer, useState } from 'react';
import { lazy, useEffect, useReducer, useState } from 'react';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
import TableEditModal, { NAMES } from '../TableEditModal';
import { bulkDelete, bulkFavorite } from '../bulk';
import TagPill from '../tags/TagPill';
import { useApiPagination } from '../useApiPagination';
const FileModal = lazy(() => import('@/components/file/DashboardFile/FileModal'));
type ReducerQuery = {
state: { name: string; originalName: string; type: string; tags: string; id: string };
action: { field: string; query: string };
@@ -53,13 +55,6 @@ type ReducerQuery = {
const PER_PAGE_OPTIONS = [10, 20, 50];
const NAMES = {
name: 'Name',
originalName: 'Original name',
type: 'Type',
id: 'ID',
};
function SearchFilter({
setSearchField,
searchQuery,
@@ -87,8 +82,8 @@ function SearchFilter({
return (
<TextInput
label={NAMES[field]}
placeholder={`Search by ${NAMES[field].toLowerCase()}`}
label={NAMES[field as keyof typeof NAMES]}
placeholder={`Search by ${NAMES[field as keyof typeof NAMES].toLowerCase()}`}
value={searchQuery[field]}
onChange={onChange}
size='sm'
@@ -178,65 +173,62 @@ function TagsFilter({
);
}
export default function FileTable({ id }: { id?: string }) {
export default function FileTable({
id,
tableEdit,
idSearch,
}: {
id?: string;
tableEdit: {
open: boolean;
setOpen: (open: boolean) => void;
};
idSearch: {
open: boolean;
setOpen: (open: boolean) => void;
};
}) {
const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
const fields = useFileTableSettingsStore((state) => state.fields);
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
'/api/user/folders?noincl=true',
);
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [perpage, setPerpage] = useState<number>(20);
const [sort, setSort] = useQueryState(
'sort',
parseAsStringLiteral([
'id',
'createdAt',
'updatedAt',
'deletesAt',
'name',
'originalName',
'size',
'type',
'views',
'favorite',
]).withDefault('createdAt'),
);
const [order, setOrder] = useQueryState<'asc' | 'desc'>(
'order',
parseAsStringLiteral(['asc', 'desc']).withDefault('desc'),
);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
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 [idSearchOpen, setIdSearchOpen] = useQueryState('idsearch', parseAsBoolean.withDefault(false));
const [searchField, setSearchField] = useState<'name' | 'originalName' | 'type' | 'tags' | 'id'>('name');
const [searchQuery, setSearchQuery] = useReducer(
(state: ReducerQuery['state'], action: ReducerQuery['action']) => {
return {
...state,
[action.field]: action.query,
};
},
(
_state: { name: string; originalName: string; type: string; tags: string; id: string },
action: { field: keyof ReducerQuery['state']; query: string },
) => ({
name: action.field === 'name' ? action.query : '',
originalName: action.field === 'originalName' ? action.query : '',
type: action.field === 'type' ? action.query : '',
tags: action.field === 'tags' ? action.query : '',
id: action.field === 'id' ? action.query : '',
}),
{ name: '', originalName: '', type: '', tags: '', id: '' },
);
const [debouncedQuery, setDebouncedQuery] = useState(searchQuery);
useEffect(() => {
if (idSearchOpen) return;
setSearchQuery({
field: 'id',
query: '',
});
}, [idSearchOpen]);
useEffect(() => {
const handler = setTimeout(() => setDebouncedQuery(searchQuery), 300);
return () => clearTimeout(handler);
}, [searchQuery]);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const combobox = useCombobox();
@@ -269,26 +261,112 @@ export default function FileTable({ id }: { id?: string }) {
}),
});
useEffect(() => {
if (data && selectedFile) {
const file = data.page.find((x) => x.id === selectedFile.id);
const [selectedFileId, setSelectedFile] = useState<string | null>(null);
const selectedFile = selectedFileId
? (data?.page.find((file) => file.id === selectedFileId) ?? null)
: null;
if (file) {
setSelectedFile(file);
}
}
}, [data]);
const FIELDS = [
{
accessor: 'name',
sortable: true,
filter: (
<SearchFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
field='name'
/>
),
filtering: searchField === 'name' && searchQuery.name.trim() !== '',
},
{
accessor: 'originalName',
sortable: true,
filter: (
<SearchFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
field='originalName'
/>
),
filtering: searchField === 'originalName' && searchQuery.originalName.trim() !== '',
},
{
accessor: 'tags',
sortable: false,
width: 200,
render: (file: File) => (
<ScrollArea w={180} onClick={(e) => e.stopPropagation()}>
<Flex gap='sm'>
{file.tags!.map((tag) => (
<TagPill tag={tag} key={tag.id} />
))}
</Flex>
</ScrollArea>
),
filter: (
<TagsFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
),
filtering: searchField === 'tags' && searchQuery.tags.trim() !== '',
},
{
accessor: 'type',
sortable: true,
filter: (
<SearchFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
field='type'
/>
),
filtering: searchField === 'type' && searchQuery.type.trim() !== '',
},
{ accessor: 'size', sortable: true, render: (file: File) => bytes(file.size) },
{
accessor: 'createdAt',
sortable: true,
render: (file: File) => <RelativeDate date={file.createdAt} />,
},
{
accessor: 'favorite',
sortable: true,
render: (file: File) => (file.favorite ? <Text c='yellow'>Yes</Text> : 'No'),
},
{
accessor: 'views',
sortable: true,
render: (file: File) => file.views,
},
{
accessor: 'id',
hidden: searchField !== 'id' || searchQuery.id.trim() === '',
filtering: searchField === 'id' && searchQuery.id.trim() !== '',
},
];
const visibleFields = fields.filter((f) => f.visible).map((f) => f.field);
const columns = FIELDS.filter((f) => visibleFields.includes(f.accessor as any));
columns.sort((a, b) => {
const aIndex = fields.findIndex((f) => f.field === a.accessor);
const bIndex = fields.findIndex((f) => f.field === b.accessor);
return aIndex - bIndex;
});
const unfavoriteAll = selectedFiles.every((file) => file.favorite);
useEffect(() => {
for (const field of ['name', 'originalName', 'type', 'tags', 'id'] as const) {
if (field !== searchField) {
setSearchQuery({
field,
query: '',
});
}
}
}, [searchField]);
const handler = setTimeout(() => setDebouncedQuery(searchQuery), 300);
return () => clearTimeout(handler);
}, [searchQuery]);
return (
<>
@@ -298,22 +376,12 @@ export default function FileTable({ id }: { id?: string }) {
if (!open) setSelectedFile(null);
}}
file={selectedFile}
user={id}
/>
<Box>
<Tooltip label='Search by ID'>
<ActionIcon
variant='outline'
onClick={() => {
setIdSearchOpen((open) => !open);
}}
// lol if it works it works :shrug:
style={{ position: 'relative', top: '-36.4px', left: '221px', margin: 0 }}
>
<IconGridPatternFilled size='1rem' />
</ActionIcon>
</Tooltip>
<TableEditModal opened={tableEdit.open} onCLose={() => tableEdit.setOpen(false)} />
<Box>
<Collapse in={selectedFiles.length > 0}>
<Paper withBorder p='sm' my='sm'>
<Text size='sm' c='dimmed' mb='xs'>
@@ -340,48 +408,56 @@ export default function FileTable({ id }: { id?: string }) {
variant='outline'
color='yellow'
leftSection={<IconStar size='1rem' />}
onClick={() => bulkFavorite(selectedFiles.map((x) => x.id))}
onClick={() =>
bulkFavorite(
selectedFiles.map((x) => x.id),
!unfavoriteAll,
)
}
>
Favorite {selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''}
{unfavoriteAll ? 'Unfavorite' : 'Favorite'} {selectedFiles.length} file
{selectedFiles.length > 1 ? 's' : ''}
</Button>
<Combobox
store={combobox}
withinPortal={false}
onOptionSubmit={(value) => handleAddFolder(value)}
>
<Combobox.Target>
<InputBase
rightSection={<Combobox.Chevron />}
value={folderSearch}
onChange={(event) => {
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
setFolderSearch(event.currentTarget.value);
}}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
onBlur={() => {
combobox.closeDropdown();
setFolderSearch(folderSearch || '');
}}
placeholder='Add to folder...'
rightSectionPointerEvents='none'
/>
</Combobox.Target>
{!id && (
<Combobox
store={combobox}
withinPortal={false}
onOptionSubmit={(value) => handleAddFolder(value)}
>
<Combobox.Target>
<InputBase
rightSection={<Combobox.Chevron />}
value={folderSearch}
onChange={(event) => {
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
setFolderSearch(event.currentTarget.value);
}}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
onBlur={() => {
combobox.closeDropdown();
setFolderSearch(folderSearch || '');
}}
placeholder='Add to folder...'
rightSectionPointerEvents='none'
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{folders
?.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase().trim()))
.map((f) => (
<Combobox.Option value={f.id} key={f.id}>
{f.name}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
<Combobox.Dropdown>
<Combobox.Options>
{folders
?.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase().trim()))
.map((f) => (
<Combobox.Option value={f.id} key={f.id}>
{f.name}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
)}
</Group>
<Button
@@ -398,8 +474,8 @@ export default function FileTable({ id }: { id?: string }) {
</Paper>
</Collapse>
<Collapse in={idSearchOpen}>
<Paper withBorder p='sm' my='sm'>
<Collapse in={idSearch.open}>
<Paper withBorder p='sm' mt='sm'>
<TextInput
placeholder='Search by ID'
value={searchQuery.id}
@@ -417,80 +493,13 @@ export default function FileTable({ id }: { id?: string }) {
{/* @ts-ignore */}
<DataTable
mt='xs'
borderRadius='sm'
withTableBorder
minHeight={200}
records={data?.page ?? []}
columns={[
{
accessor: 'name',
sortable: true,
filter: (
<SearchFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
field='name'
/>
),
filtering: searchField === 'name' && searchQuery.name.trim() !== '',
},
{
accessor: 'tags',
sortable: false,
width: 200,
render: (file) => (
<ScrollArea w={180} onClick={(e) => e.stopPropagation()}>
<Flex gap='sm'>
{file.tags!.map((tag) => (
<TagPill tag={tag} key={tag.id} />
))}
</Flex>
</ScrollArea>
),
filter: (
<TagsFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
),
filtering: searchField === 'tags' && searchQuery.tags.trim() !== '',
},
{
accessor: 'type',
sortable: true,
filter: (
<SearchFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
field='type'
/>
),
filtering: searchField === 'type' && searchQuery.type.trim() !== '',
},
{ accessor: 'size', sortable: true, render: (file) => bytes(file.size) },
{
accessor: 'createdAt',
sortable: true,
render: (file) => <RelativeDate date={file.createdAt} />,
},
{
accessor: 'favorite',
sortable: true,
render: (file) => (file.favorite ? <Text c='yellow'>Yes</Text> : 'No'),
},
{
accessor: 'views',
sortable: true,
render: (file) => file.views,
},
{
accessor: 'id',
hidden: searchField !== 'id' || searchQuery.id.trim() === '',
filtering: searchField === 'id' && searchQuery.id.trim() !== '',
},
...columns,
{
accessor: 'actions',
textAlign: 'right',
@@ -503,7 +512,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>
@@ -563,7 +572,7 @@ export default function FileTable({ id }: { id?: string }) {
setSort(data.columnAccessor as any);
setOrder(data.direction);
}}
onCellClick={({ record }) => setSelectedFile(record)}
onCellClick={({ record }) => setSelectedFile(record.id)}
selectedRecords={selectedFiles}
onSelectedRecordsChange={setSelectedFiles}
paginationText={({ from, to, totalRecords }) => `${from} - ${to} / ${totalRecords} files`}

View File

@@ -1,3 +1,4 @@
import { useQueryState } from '@/lib/hooks/useQueryState';
import {
Button,
Center,
@@ -11,23 +12,18 @@ import {
Text,
Title,
} from '@mantine/core';
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { parseAsInteger, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { IconFilesOff, IconFileUpload } from '@tabler/icons-react';
import { lazy, Suspense, useState } from 'react';
import { Link } from 'react-router-dom';
import { useApiPagination } from '../useApiPagination';
const DashboardFile = dynamic(() => import('@/components/file/DashboardFile'), {
loading: () => <Skeleton height={350} animate />,
});
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
export default function Files({ id }: { id?: string }) {
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [perpage, setPerpage] = useState<number>(15);
const [cachedPages, setCachedPages] = useState<number>(1);
const [page, setPage] = useQueryState('page', 1);
const [perpage, setPerpage] = useState(15);
const { data, isLoading } = useApiPagination({
page,
@@ -35,15 +31,10 @@ export default function Files({ id }: { id?: string }) {
id,
});
useEffect(() => {
if (data?.pages) {
setCachedPages(data.pages);
}
}, [data?.pages]);
const from = (page - 1) * perpage + 1;
const to = Math.min(page * perpage, data?.total ?? 0);
const totalRecords = data?.total ?? 0;
const cachedPages = data?.pages ?? 1;
return (
<>
@@ -60,7 +51,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} id={id} />
</Suspense>
))
) : (
<Paper withBorder p='sm'>
<Center>
@@ -75,7 +70,7 @@ export default function Files({ id }: { id?: string }) {
size='compact-sm'
leftSection={<IconFileUpload size='1rem' />}
component={Link}
href='/dashboard/upload/file'
to='/dashboard/upload/file'
>
Upload a file
</Button>

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,10 +3,6 @@ import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { ActionIcon, Anchor, Box, Checkbox, Group, Tooltip } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { copyFolderUrl, deleteFolder, editFolderVisibility, editFolderUploads } from '../actions';
import {
IconCopy,
IconFiles,
@@ -16,9 +12,14 @@ import {
IconShare,
IconShareOff,
IconTrashFilled,
IconZip,
} from '@tabler/icons-react';
import ViewFilesModal from '../ViewFilesModal';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useMemo, useState } from 'react';
import useSWR from 'swr';
import { copyFolderUrl, deleteFolder, editFolderUploads, editFolderVisibility } from '../actions';
import EditFolderNameModal from '../EditFolderNameModal';
import ViewFilesModal from '../ViewFilesModal';
export default function FolderTableView() {
const clipboard = useClipboard();
@@ -29,28 +30,23 @@ export default function FolderTableView() {
columnAccessor: 'createdAt',
direction: 'desc',
});
const [sorted, setSorted] = useState<Folder[]>(data ?? []);
const [selectedFolder, setSelectedFolder] = useState<Folder | null>(null);
const [editNameOpen, setEditNameOpen] = useState<Folder | null>(null);
useEffect(() => {
if (data) {
const sorted = data.sort((a, b) => {
const cl = sortStatus.columnAccessor as keyof Folder;
const sorted = useMemo<Folder[]>(() => {
if (!data) return [];
return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1;
});
const { columnAccessor, direction } = sortStatus;
const key = columnAccessor as keyof Folder;
setSorted(sorted);
}
}, [sortStatus]);
return [...data].sort((a, b) => {
const av = a[key]!;
const bv = b[key]!;
useEffect(() => {
if (data) {
setSorted(data);
}
}, [data]);
if (av === bv) return 0;
return direction === 'asc' ? (av > bv ? 1 : -1) : av < bv ? 1 : -1;
});
}, [data, sortStatus]);
return (
<>
@@ -169,6 +165,14 @@ export default function FolderTableView() {
<IconPencil size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Export folder as ZIP'>
<ActionIcon
color='blue'
onClick={() => window.open(`/api/user/folders/${folder.id}/export`, '_blank')}
>
<IconZip size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Delete Folder'>
<ActionIcon
color='red'

View File

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

View File

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

View File

@@ -1,14 +1,14 @@
import RelativeDate from '@/components/RelativeDate';
import { Response } from '@/lib/api/response';
import { Invite } from '@/lib/db/models/invite';
import { useSettingsStore } from '@/lib/store/settings';
import { ActionIcon, Anchor, Box, Group, Tooltip } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { IconCopy, IconTrashFilled } from '@tabler/icons-react';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useEffect, useState } from 'react';
import { useMemo, useState } from 'react';
import useSWR from 'swr';
import { copyInviteUrl, deleteInvite } from '../actions';
import { useSettingsStore } from '@/lib/store/settings';
export default function InviteTableView() {
const clipboard = useClipboard();
@@ -20,25 +20,21 @@ export default function InviteTableView() {
columnAccessor: 'createdAt',
direction: 'desc',
});
const [sorted, setSorted] = useState<Invite[]>(data ?? []);
useEffect(() => {
if (data) {
const sorted = data.sort((a, b) => {
const cl = sortStatus.columnAccessor as keyof Invite;
const sorted = useMemo<Invite[]>(() => {
if (!data) return [];
return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1;
});
const { columnAccessor, direction } = sortStatus;
const key = columnAccessor as keyof Invite;
setSorted(sorted);
}
}, [sortStatus]);
return [...data].sort((a, b) => {
const av = a[key]!;
const bv = b[key]!;
useEffect(() => {
if (data) {
setSorted(data);
}
}, [data]);
if (av === bv) return 0;
return direction === 'asc' ? (av > bv ? 1 : -1) : av < bv ? 1 : -1;
});
}, [data, sortStatus]);
return (
<>

View File

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

View File

@@ -1,18 +1,17 @@
import { Box, Button, Group, Modal, Paper, SimpleGrid, Text, Title, Tooltip } from '@mantine/core';
import { DatePicker } from '@mantine/dates';
import { IconCalendarSearch, IconCalendarTime } from '@tabler/icons-react';
import dynamic from 'next/dynamic';
import { useEffect, useState } from 'react';
import dayjs from 'dayjs';
import { lazy, 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';
import { useApiStats } from './useStats';
const StatsCards = dynamic(() => import('./parts/StatsCards'));
const StatsTables = dynamic(() => import('./parts/StatsTables'));
const StorageGraph = dynamic(() => import('./parts/StorageGraph'));
const ViewsGraph = dynamic(() => import('./parts/ViewsGraph'));
const StorageGraph = lazy(() => import('./parts/StorageGraph'));
const ViewsGraph = lazy(() => import('./parts/ViewsGraph'));
const StatsCards = lazy(() => import('./parts/StatsCards'));
const StatsTables = lazy(() => import('./parts/StatsTables'));
export default function DashboardMetrics() {
const today = dayjs();
@@ -36,9 +35,10 @@ export default function DashboardMetrics() {
setDateRange(value);
};
useEffect(() => {
if (allTime) setDateRange([null, null]);
}, [allTime]);
const showAllTime = () => {
setAllTime(true);
setDateRange([null, null]);
};
return (
<>
@@ -119,7 +119,7 @@ export default function DashboardMetrics() {
size='compact-sm'
variant='outline'
leftSection={<IconCalendarTime size='1rem' />}
onClick={() => setAllTime(true)}
onClick={() => showAllTime()}
disabled={allTime}
>
Show All Time

View File

@@ -99,8 +99,7 @@ export default function StatsTables({ data }: { data: Metric[] }) {
const recent = data[0]; // it is sorted by desc so 0 is the first one.
if (recent.data.filesUsers.length === 0) return null;
if (recent.data.urlsUsers.length === 0) return null;
if (recent.data.filesUsers.length === 0 || recent.data.urlsUsers.length === 0) return null;
return (
<>
@@ -121,7 +120,7 @@ export default function StatsTables({ data }: { data: Metric[] }) {
.sort((a, b) => b.sum - a.sum)
.map((count, i) => (
<Table.Tr key={i}>
<Table.Td>{count.username}</Table.Td>
<Table.Td>{count.username ?? '[unknown]'}</Table.Td>
<Table.Td>{count.sum}</Table.Td>
<Table.Td>{bytes(count.storage)}</Table.Td>
<Table.Td>{count.views}</Table.Td>
@@ -147,7 +146,7 @@ export default function StatsTables({ data }: { data: Metric[] }) {
.sort((a, b) => b.sum - a.sum)
.map((count, i) => (
<Table.Tr key={i}>
<Table.Td>{count.username}</Table.Td>
<Table.Td>{count.username ?? '[unknown]'}</Table.Td>
<Table.Td>{count.sum}</Table.Td>
<Table.Td>{count.views}</Table.Td>
</Table.Tr>

View File

@@ -0,0 +1,19 @@
import { ActionIcon } from '@mantine/core';
import { IconPlayerPlayFilled } from '@tabler/icons-react';
const ICON_SIZE = '1.75rem';
export default function ActionButton({ onClick, Icon }: { onClick: () => void; Icon?: React.FC<any> }) {
return (
<ActionIcon
onClick={onClick}
variant='filled'
color='blue'
radius='md'
size='xl'
className='zip-click-action-button'
>
{Icon ? <Icon size={ICON_SIZE} /> : <IconPlayerPlayFilled size={ICON_SIZE} />}
</ActionIcon>
);
}

View File

@@ -1,9 +1,9 @@
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { Button } from '@mantine/core';
import { modals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { IconTrashFilled } from '@tabler/icons-react';
import ActionButton from '../ActionButton';
export default function ClearTempButton() {
const openModal = () =>
@@ -30,11 +30,5 @@ export default function ClearTempButton() {
},
});
return (
<>
<Button size='sm' leftSection={<IconTrashFilled size='1rem' />} onClick={openModal}>
Clear Temp Files
</Button>
</>
);
return <ActionButton onClick={openModal} Icon={IconTrashFilled} />;
}

View File

@@ -1,10 +1,10 @@
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { Button } from '@mantine/core';
import { modals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { IconTrashFilled } from '@tabler/icons-react';
import useSWR from 'swr';
import ActionButton from '../ActionButton';
export default function ClearZerosButton() {
const { data } = useSWR<Response['/api/server/clear_zeros']>('/api/server/clear_zeros');
@@ -32,11 +32,5 @@ export default function ClearZerosButton() {
},
});
return (
<>
<Button size='sm' leftSection={<IconTrashFilled size='1rem' />} onClick={openModal}>
Clear Zero Byte Files
</Button>
</>
);
return <ActionButton onClick={openModal} Icon={IconTrashFilled} />;
}

View File

@@ -2,8 +2,9 @@ import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { Button, Group, Modal, Stack, Switch } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconVideo, IconVideoOff } from '@tabler/icons-react';
import { IconVideoOff, IconVideoPlusFilled } from '@tabler/icons-react';
import { useState } from 'react';
import ActionButton from '../ActionButton';
export default function GenThumbsButton() {
const [rerun, setRerun] = useState(false);
@@ -53,9 +54,8 @@ export default function GenThumbsButton() {
</Button>
</Group>
</Modal>
<Button size='sm' leftSection={<IconVideo size='1rem' />} onClick={() => setOpen(true)}>
Generate Thumbnails
</Button>
<ActionButton onClick={() => setOpen(true)} Icon={IconVideoPlusFilled} />
</>
);
}

View File

@@ -0,0 +1,143 @@
import { Alert, Box, Button, List, Modal, Code, Group, Divider, Checkbox, Pill } from '@mantine/core';
import { IconAlertCircle, IconDownload } from '@tabler/icons-react';
import { useState } from 'react';
export default function ExportButton() {
const [open, setOpen] = useState(false);
const [noMetrics, setNoMetrics] = useState(false);
return (
<>
<Modal opened={open} onClose={() => setOpen(false)} size='lg' title='Are you sure?'>
<Box px='sm'>
<p>The export provides a complete snapshot of Ziplines data and environment. It includes:</p>
<List>
<List.Item>
<b>Users:</b> Account information including usernames, optional passwords, avatars, roles, view
settings, and optional TOTP secrets.
</List.Item>
<List.Item>
<b>Passkeys:</b> Registered WebAuthn passkeys with creation dates, last-used timestamps, and
credential registration data.
</List.Item>
<List.Item>
<b>User Quotas:</b> Quota settings such as max bytes, max files, max URLs, and quota types.
</List.Item>
<List.Item>
<b>OAuth Providers:</b> Linked OAuth accounts including provider type, tokens, and OAuth IDs.
</List.Item>
<List.Item>
<b>User Tags:</b> Tags created by users, including names, colors, and associated file IDs.
</List.Item>
<List.Item>
<b>Files:</b> Metadata about uploaded files including size, type, timestamps, expiration, views,
password protection, owner, and folder association.
<i> (Actual file contents are not included.)</i>
</List.Item>
<List.Item>
<b>Folders:</b> Folder metadata including visibility settings, upload permissions, file lists,
and ownership.
</List.Item>
<List.Item>
<b>URLs:</b> Metadata for shortened URLs including destinations, vanity codes, view counts,
passwords, and user assignments.
</List.Item>
<List.Item>
<b>Thumbnails:</b> Thumbnail path and associated file ID.
<i> (Image data is not included.)</i>
</List.Item>
<List.Item>
<b>Invites:</b> Invite codes, creation/expiration dates, and usage counts.
</List.Item>
<List.Item>
<b>Metrics:</b> System and usage statistics stored internally by Zipline.
</List.Item>
</List>
<p>
Additionally, the export includes <b>system-specific information</b>:
</p>
<List>
<List.Item>
<b>CPU Count:</b> The number of available processor cores.
</List.Item>
<List.Item>
<b>Hostname:</b> The host systems network identifier.
</List.Item>
<List.Item>
<b>Architecture:</b> The hardware architecture (e.g., <Code>x64</Code>, <Code>arm64</Code>).
</List.Item>
<List.Item>
<b>Platform:</b> The operating system platform (e.g., <Code>linux</Code>, <Code>darwin</Code>).
</List.Item>
<List.Item>
<b>OS Release:</b> The OS or kernel version.
</List.Item>
<List.Item>
<b>Environment Variables:</b> A full snapshot of environment variables at the time of export.
</List.Item>
<List.Item>
<b>Versions:</b> The Zipline version, Node version, and export format version.
</List.Item>
</List>
<Divider my='md' />
<Checkbox
label='Exclude Metrics Data'
description='Exclude system and usage metrics from the export. This can reduce the export file size.'
checked={noMetrics}
onChange={() => setNoMetrics((val) => !val)}
/>
<Divider my='md' />
<Alert color='red' icon={<IconAlertCircle size='1rem' />} title='Warning' my='md'>
This export contains a significant amount of sensitive data, including user accounts,
authentication credentials, environment variables, and system metadata. Handle this file securely
and do not share it with untrusted parties.
</Alert>
<Group grow my='md'>
<Button onClick={() => setOpen(false)} color='red'>
Cancel
</Button>
<Button
component='a'
href={`/api/server/export${noMetrics ? '?nometrics=true' : ''}`}
target='_blank'
rel='noreferrer'
leftSection={<IconDownload size='1rem' />}
onClick={() => setOpen(false)}
>
Download Export
</Button>
</Group>
</Box>
</Modal>
<Button
size='xl'
fullWidth
onClick={() => setOpen(true)}
leftSection={<IconDownload size='1rem' />}
rightSection={<Pill>V4</Pill>}
>
Export Data
</Button>
</>
);
}

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