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
212 changed files with 10982 additions and 5699 deletions

View File

@@ -1,53 +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 release (ghcr.io/diced/zipline or ghcr.io/diced/zipline:latest)
- Latest commit (ghcr.io/diced/zipline: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
- Chromium-based Mobile (Chrome, Edge, Brave, Android WebView, etc)
- 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) 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

@@ -44,16 +44,7 @@ jobs:
- name: Install
run: pnpm install
- name: Lint
run: pnpm lint
- name: Generate Prisma
run: pnpm build:prisma
- name: Type Check
run: pnpm build:types
- name: Build
env:
ZIPLINE_BUILD: 'true'
run: pnpm build:skip
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

4
.gitignore vendored
View File

@@ -48,4 +48,6 @@ yarn-error.log*
uploads*/
*.crt
*.key
src/prisma
src/prisma
.memory.log*
openapi.json

View File

@@ -31,8 +31,9 @@ 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 NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN ZIPLINE_BUILD=true pnpm run build
@@ -45,7 +46,7 @@ COPY --from=builder /zipline/build ./build
COPY --from=builder /zipline/mimes.json ./mimes.json
COPY --from=builder /zipline/code.json ./code.json
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 reactHooksPlugin from 'eslint-plugin-react-hooks';
import reactPlugin from 'eslint-plugin-react';
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import 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,63 +22,53 @@ const gitignorePatterns = gitignoreContent
.filter((line) => line.trim() && !line.startsWith('#'))
.map((pattern) => pattern.trim());
export default tseslint.config(
import { defineConfig } from 'eslint/config';
export default defineConfig(
tseslint.configs.recommended,
jsxA11yPlugin.flatConfigs.recommended,
reactPlugin.configs.flat.recommended,
reactHooksPlugin.configs.flat.recommended,
reactRefreshPlugin.configs.vite,
{ ignores: gitignorePatterns },
{
extends: [
tseslint.configs.recommended,
reactHooksPlugin.configs['recommended-latest'],
reactRefreshPlugin.configs.vite,
],
},
{
files: ['**/*.{js,mjs,cjs,ts,tsx}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaFeatures: { jsx: true },
},
},
plugins: {
'unused-imports': unusedImports,
prettier: prettier,
react: reactPlugin,
'jsx-a11y': jsxA11yPlugin,
},
rules: {
...reactPlugin.configs.recommended.rules,
plugins: {
react: reactPlugin,
'react-hooks': reactHooksPlugin,
prettier,
'unused-imports': unusedImports,
},
rules: {
...prettierConfig.rules,
'prettier/prettier': [
'error',
{},
{
fileInfoOptions: {
withNodeModules: false,
},
},
],
'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',
@@ -87,28 +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',
},
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

@@ -2,26 +2,16 @@
"name": "zipline",
"private": true,
"license": "MIT",
"version": "4.2.3",
"version": "4.4.1",
"scripts": {
"lint": "eslint .",
"build:skip": "pnpm run --stream build:prisma && pnpm run --stream build:server && pnpm run --stream build:client",
"build": "pnpm run --stream lint && pnpm run --stream build:prisma && pnpm run --stream build:types && pnpm run --stream build:server && pnpm run --stream build:client",
"build:types": "tsc",
"build:prisma": "prisma generate --no-hints",
"build:server": "tsup",
"build:client": "vite build && pnpm run --stream \"/^build-ssr:.*/\"",
"build-ssr:view": "vite build --ssr ssr-view/server.tsx -m ssr-view --outDir ../../build/ssr --emptyOutDir=false",
"build-ssr:view-url": "vite build --ssr ssr-view-url/server.tsx -m ssr-view-url --outDir ../../build/ssr --emptyOutDir=false",
"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",
@@ -31,94 +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",
"cookie": "^1.0.2",
"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",
"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.7.1",
"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.39.0",
"vite": "^7.1.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": {
"@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.2.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.32.0",
"@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.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"eslint-plugin-unused-imports": "^4.1.4",
"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.90.0",
"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"

5504
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,7 +1,7 @@
generator client {
provider = "prisma-client"
output = "../src/prisma"
moduleFormat = "cjs"
output = "../src/prisma"
moduleFormat = "cjs"
previewFeatures = ["queryCompiler", "driverAdapters"]
}
@@ -20,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")
@@ -30,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)
@@ -55,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)
@@ -104,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)
@@ -138,7 +146,7 @@ model Zipline {
pwaThemeColor String @default("#000000")
pwaBackgroundColor String @default("#000000")
domains String[] @default([])
domains String[] @default([])
}
model User {
@@ -292,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 .'),
);

View File

@@ -1,10 +1,31 @@
import { ModalsProvider } from '@mantine/modals';
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,
@@ -37,6 +58,7 @@ export default function Root({
},
centered: true,
}}
modals={contextModals}
>
<Notifications zIndex={10000000} />
<Outlet />

View File

@@ -5,7 +5,7 @@ export default function DashboardErrorBoundary(props: Record<string, any>) {
<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}
details={{ ...props, type: 'dashboard' }}
/>
);
}

View File

@@ -1,4 +1,6 @@
import { Container, Paper, Stack, Text, Title } from '@mantine/core';
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,
@@ -9,6 +11,13 @@ export default function GenericError({
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'>
@@ -18,7 +27,9 @@ export default function GenericError({
</Text>
{details && (
<Paper withBorder px={3} py={3}>
<pre style={{ margin: 0 }}>{JSON.stringify(details, null, 2)}</pre>
<ScrollArea>
<pre style={{ margin: 0 }}>{JSON.stringify({ routeError, details }, null, 2)}</pre>
</ScrollArea>
</Paper>
)}
</Stack>

View File

@@ -5,7 +5,7 @@ export default function RootErrorBoundary(props: Record<string, any>) {
<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}
details={{ ...props, type: 'root' }}
/>
);
}

View File

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

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 'react-router-dom';
export default function FourOhFour() {
useTitle('404');
return (
<Center h='100vh'>
<Stack>

View File

@@ -2,7 +2,7 @@ import ExternalAuthButton from '@/components/pages/login/ExternalAuthButton';
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import useLogin from '@/lib/hooks/useLogin';
import { authenticateWeb } from '@/lib/passkey';
import { useTitle } from '@/lib/hooks/useTitle';
import {
Button,
Center,
@@ -21,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,
@@ -32,10 +33,9 @@ import {
IconX,
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { Link, redirect, useLocation, useNavigate } from 'react-router-dom';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import GenericError from '../../error/GenericError';
import { useTitle } from '@/lib/hooks/useTitle';
export default function Login() {
useTitle('Login');
@@ -86,6 +86,9 @@ export default function Login() {
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) => {
@@ -128,9 +131,24 @@ export default function Login() {
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);
@@ -163,7 +181,7 @@ export default function Login() {
);
if (provider) {
redirect(`/api/auth/oauth/${provider.toLowerCase()}`);
window.location.href = `/api/auth/oauth/${provider.toLowerCase()}`;
}
}
}, [willRedirect, config]);
@@ -299,6 +317,7 @@ export default function Login() {
<TextInput
size='md'
placeholder='Enter your username...'
autoComplete='username'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
@@ -310,6 +329,7 @@ export default function Login() {
<PasswordInput
size='md'
placeholder='Enter your password...'
autoComplete='current-password'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
@@ -336,7 +356,7 @@ export default function Login() {
<Divider label='or' />
)}
{config.mfa.passkeys && (
{config.mfa.passkeys && browserSupportsWebAuthn() && (
<Button
onClick={handlePasskeyLogin}
size='md'

View File

@@ -1,5 +1,6 @@
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { useTitle } from '@/lib/hooks/useTitle';
import {
Button,
Center,
@@ -15,13 +16,12 @@ import {
Title,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { notifications, showNotification } from '@mantine/notifications';
import { IconLogin, IconPlus, IconUserPlus, IconX } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { Link, redirect, useLocation, useNavigate } from 'react-router-dom';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import useSWR, { mutate } from 'swr';
import GenericError from '../../error/GenericError';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Register');
@@ -30,7 +30,6 @@ export function Component() {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [invite, setInvite] = useState<any>(null);
const {
data: config,
@@ -44,6 +43,19 @@ export function Component() {
});
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: {
@@ -55,13 +67,16 @@ export function Component() {
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) {
redirect('/dashboard');
navigate('/dashboard');
} else {
setLoading(false);
}
@@ -69,21 +84,9 @@ export function Component() {
}, []);
useEffect(() => {
(async () => {
if (!code) return;
if (!config) return;
const res = await fetch(`/api/auth/invite/web?code=${code}`);
if (res.ok) {
const json = await res.json();
setInvite(json.invite);
} else {
redirect('/auth/login');
}
})();
}, [code]);
useEffect(() => {
if (!config?.features.userRegistration) {
if (!config?.features.userRegistration && !code) {
navigate('/auth/login');
}
}, [code, config]);
@@ -122,7 +125,7 @@ export function Component() {
});
mutate('/api/user');
redirect('/dashboard');
navigate('/dashboard');
}
};
@@ -138,6 +141,22 @@ export function Component() {
);
}
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 && (
@@ -183,8 +202,13 @@ export function Component() {
{invite && (
<Text ta='center' size='sm' c='dimmed'>
Youve been invited to join <b>{config?.website?.title ?? 'Zipline'}</b> by{' '}
<b>{invite.inviter?.username}</b>
Youve been invited to join <b>{config?.website?.title ?? 'Zipline'}</b>
{invite.inviter && (
<>
{' '}
by <b>{invite.inviter.username}</b>
</>
)}
</Text>
)}
@@ -193,6 +217,7 @@ export function Component() {
<TextInput
size='md'
placeholder='Enter your username...'
autoComplete='username'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
@@ -204,6 +229,7 @@ export function Component() {
<PasswordInput
size='md'
placeholder='Enter your password...'
autoComplete='new-password'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,

View File

@@ -65,6 +65,9 @@ export function Component() {
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) => {
@@ -180,12 +183,14 @@ export function Component() {
<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>

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

@@ -1,5 +1,23 @@
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');

View File

@@ -1,4 +1,5 @@
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';
@@ -19,6 +20,8 @@ export async function loader({ params }: { params: Params<string> }) {
export function Component() {
const { folder } = useLoaderData<typeof loader>();
useTitle(folder.name ?? '');
return (
<>
<Container my='lg'>

View File

@@ -2,15 +2,17 @@ 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 { Link, Params, useLoaderData } from 'react-router-dom';
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 new Response('Folder not found', { status: 404 });
throw data('Folder not found', { status: 404 });
}
return {
folder: (await res.json()) as Response['/api/server/folder/[id]'],
};
@@ -26,6 +28,8 @@ export function Component() {
revalidateIfStale: false,
});
useTitle(`Upload to ${folder.name ?? 'folder'}`);
return (
<>
<Container my='lg'>

View File

@@ -26,6 +26,7 @@ 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>>>>;
@@ -44,10 +45,19 @@ export default function ViewFileId() {
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
@@ -121,6 +131,7 @@ export default function ViewFileId() {
__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}`,

View File

@@ -1,7 +1,7 @@
import Layout from '@/components/Layout';
import { Response as ApiResponse } from '@/lib/api/response';
import { isAdministrator } from '@/lib/role';
import { createBrowserRouter, redirect } from 'react-router-dom';
import { createBrowserRouter, data, redirect } from 'react-router-dom';
import DashboardErrorBoundary from './error/DashboardErrorBoundary';
import RootErrorBoundary from './error/RootErrorBoundary';
import FourOhFour from './pages/404';
@@ -10,15 +10,19 @@ import Logout from './pages/auth/logout';
import Root from './Root';
export async function dashboardLoader() {
const res = await fetch('/api/server/settings/web');
if (!res.ok) {
return redirect('/auth/login');
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 });
}
const data = await res.json();
console.log('Loaded settings:', data);
return data as ApiResponse['/api/server/settings/web'];
}
export const router = createBrowserRouter([
@@ -78,6 +82,7 @@ export const router = createBrowserRouter([
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: [

View File

@@ -25,7 +25,7 @@ import { createRoutes } from './routes';
export const getFile = async (id: string) =>
prisma.file.findFirst({
where: { name: id as string },
where: { name: decodeURIComponent(id) },
select: {
...fileSelect,
password: true,
@@ -88,13 +88,6 @@ export async function render(
host = proto === 'https' || zConfig.core.returnHttpsUrls ? `https://${host}` : `http://${host}`;
}
// Date normalization
(file as any).createdAt = file.createdAt.toISOString();
(file as any).updatedAt = file.updatedAt.toISOString();
(file as any).deletesAt = file.deletesAt?.toISOString() || null;
(user as any).createdAt = user.createdAt.toISOString();
(user as any).updatedAt = user.updatedAt.toISOString();
const code = await isCode(file.name);
const themes = await readThemes();
const metrics = await parserMetrics(user.id);
@@ -144,11 +137,6 @@ export async function render(
}
}
await prisma.file.update({
where: { id: file.id },
data: { views: { increment: 1 } },
});
const data = {
file,
password: hasPassword,
@@ -277,11 +265,11 @@ export async function render(
: ''
}
<title>${file.name}</title>
<title>${file.originalName ?? file.name}</title>
`;
return {
html,
meta: `${meta}\n${createZiplineSsr(data)}`,
meta: `${user.view.embed ? meta : ''}\n${createZiplineSsr(data)}`,
};
}

View File

@@ -41,6 +41,7 @@ import {
IconRefreshDot,
IconSettingsFilled,
IconShieldLockFilled,
IconStopwatch,
IconTags,
IconUpload,
IconUsersGroup,
@@ -126,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' />,

View File

@@ -65,17 +65,21 @@ export default function ThemeProvider({
}
return (
<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>
<>
{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

@@ -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,10 +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 { useUserStore } from '@/lib/store/user';
function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
return (
@@ -77,13 +80,17 @@ export default function DashboardFileType({
code?: boolean;
allowZoom?: boolean;
}) {
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);
@@ -106,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
},
@@ -119,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);
@@ -164,23 +171,22 @@ export default function DashboardFileType({
</Paper>
);
switch (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'
@@ -201,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',
@@ -217,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',
@@ -236,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
@@ -276,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)} />;
@@ -284,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

@@ -27,10 +27,14 @@ 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);

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

@@ -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,12 +6,16 @@ import FileTable from './views/FileTable';
import Files from './views/Files';
import TagsButton from './tags/TagsButton';
import PendingFilesButton from './PendingFilesButton';
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 DashboardFiles() {
const view = useViewStore((state) => state.files);
const [tableEditOpen, setTableEditOpen] = useState(false);
const [idSearchOpen, setIdSearchOpen] = useState(false);
return (
<>
<Group>
@@ -28,6 +32,27 @@ export default function DashboardFiles() {
<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 DashboardFiles() {
<Files />
</>
) : (
<FileTable />
<FileTable
idSearch={{
open: idSearchOpen,
setOpen: setIdSearchOpen,
}}
tableEdit={{
open: tableEditOpen,
setOpen: setTableEditOpen,
}}
/>
)}
</>
);

View File

@@ -5,6 +5,8 @@ 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,
@@ -32,7 +34,6 @@ import {
IconDownload,
IconExternalLink,
IconFile,
IconGridPatternFilled,
IconStar,
IconTrashFilled,
} from '@tabler/icons-react';
@@ -40,10 +41,10 @@ import { DataTable } from 'mantine-datatable';
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';
import { useQueryState } from '@/lib/hooks/useQueryState';
const FileModal = lazy(() => import('@/components/file/DashboardFile/FileModal'));
@@ -54,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,
@@ -88,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'
@@ -179,10 +173,26 @@ 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',
);
@@ -202,36 +212,23 @@ export default function FileTable({ id }: { id?: string }) {
| 'favorite'
>('createdAt');
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [idSearchOpen, setIdSearchOpen] = useState(false);
const [searchField, setSearchField] = useState<'name' | 'originalName' | 'type' | 'tags' | 'id'>('name');
const [searchQuery, setSearchQuery] = useReducer(
(state: ReducerQuery['state'], action: ReducerQuery['action']) => {
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();
@@ -264,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 (
<>
@@ -293,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'>
@@ -335,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
@@ -393,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}
@@ -412,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',
@@ -558,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,11 +12,10 @@ import {
Text,
Title,
} from '@mantine/core';
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
import { lazy, Suspense, useEffect, useState } from 'react';
import { useApiPagination } from '../useApiPagination';
import { IconFilesOff, IconFileUpload } from '@tabler/icons-react';
import { lazy, Suspense, useState } from 'react';
import { Link } from 'react-router-dom';
import { useQueryState } from '@/lib/hooks/useQueryState';
import { useApiPagination } from '../useApiPagination';
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
@@ -24,7 +24,6 @@ const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
export default function Files({ id }: { id?: string }) {
const [page, setPage] = useQueryState('page', 1);
const [perpage, setPerpage] = useState(15);
const [cachedPages, setCachedPages] = useState(1);
const { data, isLoading } = useApiPagination({
page,
@@ -32,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 (
<>
@@ -59,7 +53,7 @@ export default function Files({ id }: { id?: string }) {
) : (data?.page?.length ?? 0 > 0) ? (
data?.page.map((file) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} />
<DashboardFile file={file} id={id} />
</Suspense>
))
) : (

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

@@ -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,12 +1,12 @@
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 { lazy, 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 StorageGraph = lazy(() => import('./parts/StorageGraph'));
const ViewsGraph = lazy(() => import('./parts/ViewsGraph'));
@@ -35,9 +35,10 @@ export default function DashboardMetrics() {
setDateRange(value);
};
useEffect(() => {
if (allTime) setDateRange([null, null]);
}, [allTime]);
const showAllTime = () => {
setAllTime(true);
setDateRange([null, null]);
};
return (
<>
@@ -118,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>
</>
);
}

View File

@@ -6,7 +6,7 @@ import {
V3_SETTINGS_TRANSFORM,
validateExport,
} from '@/lib/import/version3/validateExport';
import { Alert, Button, Code, FileButton, Modal, Stack } from '@mantine/core';
import { Alert, Button, Code, FileButton, Modal, Pill, Stack } from '@mantine/core';
import { modals } from '@mantine/modals';
import { showNotification, updateNotification } from '@mantine/notifications';
import {
@@ -23,7 +23,7 @@ import Export3Details from './Export3Details';
import Export3ImportSettings from './Export3ImportSettings';
import Export3UserChoose from './Export3UserChoose';
export default function ImportButton() {
export default function ImportV3Button() {
const [open, setOpen] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [export3, setExport3] = useState<Export3 | null>(null);
@@ -93,8 +93,6 @@ export default function ImportButton() {
color: 'green',
icon: <IconDeviceFloppy size='1rem' />,
});
await fetch('/reload');
}
};
@@ -262,7 +260,7 @@ export default function ImportButton() {
return (
<>
<Modal opened={open} onClose={() => setOpen(false)} title='Import data' size='xl'>
<Modal opened={open} onClose={() => setOpen(false)} title='Import V3 Data' size='xl'>
{export3 ? (
<Button
onClick={() => {
@@ -315,8 +313,8 @@ export default function ImportButton() {
)}
</Modal>
<Button size='sm' leftSection={<IconDatabaseImport size='1rem' />} onClick={() => setOpen(true)}>
Import Data
<Button size='xl' rightSection={<Pill>V3</Pill>} onClick={() => setOpen(true)}>
Import{' '}
</Button>
</>
);

View File

@@ -0,0 +1,463 @@
import HighlightCode from '@/components/render/code/HighlightCode';
import { bytes } from '@/lib/bytes';
import { Export4 } from '@/lib/import/version4/validateExport';
import {
Accordion,
Anchor,
Avatar,
Button,
Center,
Collapse,
Paper,
ScrollArea,
Stack,
Table,
Text,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconCheck,
IconFiles,
IconFolder,
IconGraphFilled,
IconLink,
IconTag,
IconTagPlus,
IconTarget,
IconUsers,
IconVersions,
IconX,
} from '@tabler/icons-react';
function findOauthProviders(export4: Export4, userId: string) {
return export4.data.userOauthProviders.filter((provider) => provider.userId === userId);
}
function findUser(export4: Export4, userId: string) {
return export4.data.users.find((user) => user.id === userId);
}
function TextDetail({ name, children }: { name: string; children: React.ReactNode }) {
return (
<span>
<b>{name}:</b> {children}
</span>
);
}
export default function Export3Details({ export4 }: { export4: Export4 }) {
const [envOpened, { toggle: toggleEnv }] = useDisclosure(false);
const [osOpened, { toggle: toggleOs }] = useDisclosure(false);
const [reqId, reqUsername] = export4.request.user.split(':').map((s) => s.trim());
const envRows = Object.entries(export4.request.env).map(([key, value]) => (
<Table.Tr key={key}>
<Table.Td ff='monospace'>{key}</Table.Td>
<Table.Td ff='monospace'>{value}</Table.Td>
</Table.Tr>
));
const osRows = Object.entries(export4.request.os).map(([key, value]) => (
<Table.Tr key={key}>
<Table.Td ff='monospace'>{key}</Table.Td>
<Table.Td ff='monospace'>{String(value)}</Table.Td>
</Table.Tr>
));
const userRows = export4.data.users.map((user, i) => (
<Table.Tr key={i}>
<Table.Td>{user.avatar ? <Avatar src={user.avatar} size={24} radius='sm' /> : ''}</Table.Td>
<Table.Td>{user.id}</Table.Td>
<Table.Td>{user.username}</Table.Td>
<Table.Td>{user.password ? <IconCheck size='1rem' /> : <IconX size='1rem' />}</Table.Td>
<Table.Td>{{ USER: 'User', ADMIN: 'Admin', SUPERADMIN: 'Super Admin' }[user.role]}</Table.Td>
<Table.Td>
{findOauthProviders(export4, user.id)
.map((x) => x.provider.toLowerCase())
.join(', ')}
</Table.Td>
<Table.Td>
{export4.data.userQuotas.find((x) => x.userId === user.id) ? (
<IconCheck size='1rem' />
) : (
<IconX size='1rem' />
)}
</Table.Td>
<Table.Td>{export4.data.userPasskeys.filter((x) => x.userId === user.id).length}</Table.Td>
</Table.Tr>
));
const userOauthProvidersRows = export4.data.userOauthProviders.map((provider, i) => (
<Table.Tr key={i}>
<Table.Td>{findUser(export4, provider.userId)?.username ?? <i>unknown</i>}</Table.Td>
<Table.Td>{provider.provider.toLowerCase()}</Table.Td>
<Table.Td>{provider.username}</Table.Td>
<Table.Td>{provider.oauthId}</Table.Td>
</Table.Tr>
));
const fileRows = export4.data.files.map((file, i) => (
<Table.Tr key={i}>
<Table.Td>{file.name}</Table.Td>
<Table.Td>{new Date(file.createdAt).toLocaleString()}</Table.Td>
<Table.Td>{file.password ? <IconCheck size='1rem' /> : <IconX size='1rem' />}</Table.Td>
<Table.Td>{bytes(file.size)}</Table.Td>
<Table.Td>
{file.userId ? (findUser(export4, file.userId)?.username ?? <i>unknown</i>) : <i>unknown</i>}
</Table.Td>
</Table.Tr>
));
const folderRows = export4.data.folders.map((folder, i) => (
<Table.Tr key={i}>
<Table.Td>{folder.name}</Table.Td>
<Table.Td>
{folder.userId ? (findUser(export4, folder.userId)?.username ?? <i>unknown</i>) : <i>unknown</i>}
</Table.Td>
<Table.Td>{folder.public ? 'Yes' : 'No'}</Table.Td>
<Table.Td>{new Date(folder.createdAt).toLocaleString()}</Table.Td>
<Table.Td>{folder.files.length}</Table.Td>
</Table.Tr>
));
const urlRows = export4.data.urls.map((url, i) => (
<Table.Tr key={i}>
<Table.Td>{url.code}</Table.Td>
<Table.Td>
{url.userId ? (findUser(export4, url.userId)?.username ?? <i>unknown</i>) : <i>unknown</i>}
</Table.Td>
<Table.Td>
<Anchor href={url.destination}>{url.destination}</Anchor>
</Table.Td>
<Table.Td>{url.vanity ?? ''}</Table.Td>
<Table.Td>{url.password ? <IconCheck size='1rem' /> : <IconX size='1rem' />}</Table.Td>
<Table.Td>{new Date(url.createdAt).toLocaleString()}</Table.Td>
<Table.Td>{url.enabled ? <IconCheck size='1rem' /> : <IconX size='1rem' />}</Table.Td>
</Table.Tr>
));
const invitesRows = export4.data.invites.map((invite, i) => (
<Table.Tr key={i}>
<Table.Td>{invite.code}</Table.Td>
<Table.Td>
{invite.inviterId ? (
(findUser(export4, invite.inviterId)?.username ?? <i>unknown</i>)
) : (
<i>unknown</i>
)}
</Table.Td>
<Table.Td>{new Date(invite.createdAt).toLocaleString()}</Table.Td>
<Table.Td>{invite.uses}</Table.Td>
</Table.Tr>
));
const tagsRows = export4.data.userTags.map((tag, i) => (
<Table.Tr key={i}>
<Table.Td>
{tag.userId ? (findUser(export4, tag.userId)?.username ?? <i>unknown</i>) : <i>unknown</i>}
</Table.Td>
<Table.Td c={tag.color ?? undefined}>{tag.name}</Table.Td>
<Table.Td>{tag.files.length}</Table.Td>
</Table.Tr>
));
return (
<>
<Text c='dimmed' size='sm' my='xs'>
This data is not sent to the server. It is parsed and displayed in the browser. Data is only sent to
the server when you click the &quot;Import&quot; button.
</Text>
<Accordion defaultValue='version' variant='contained'>
<Accordion.Item value='version'>
<Accordion.Control icon={<IconVersions size='1rem' />}>Version Details</Accordion.Control>
<Accordion.Panel>
<Stack gap={2}>
<TextDetail name='Export Version'>{export4.versions.export}</TextDetail>
<TextDetail name='Node'>{export4.versions.node}</TextDetail>
<TextDetail name='Zipline'>v{export4.versions.zipline}</TextDetail>
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='request'>
<Accordion.Control icon={<IconTarget size='1rem' />}>Request Details</Accordion.Control>
<Accordion.Panel>
<Stack gap={2}>
<TextDetail name='User'>
{reqUsername} ({reqId})
</TextDetail>
<TextDetail name='At'>{new Date(export4.request.date).toLocaleString()}</TextDetail>
<Button my='xs' onClick={toggleOs} size='compact-sm'>
{envOpened ? 'Hide' : 'Show'} OS Details
</Button>
<Collapse in={osOpened}>
<Paper withBorder>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th w={300}>Key</Table.Th>
<Table.Th>Value</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{osRows}</Table.Tbody>
</Table>
</Paper>
<Button my='xs' onClick={toggleOs} size='compact-sm'>
Hide OS Details
</Button>
</Collapse>
<Button my='xs' onClick={toggleEnv} size='compact-sm'>
{envOpened ? 'Hide' : 'Show'} Environment
</Button>
<Collapse in={envOpened}>
<Paper withBorder>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th w={300}>Key</Table.Th>
<Table.Th>Value</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{envRows}</Table.Tbody>
</Table>
</Paper>
<Button my='xs' onClick={toggleEnv} size='compact-sm'>
Hide Environment
</Button>
</Collapse>
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='users'>
<Accordion.Control icon={<IconUsers size='1rem' />}>Users</Accordion.Control>
<Accordion.Panel>
<Paper withBorder>
{Object.keys(export4.data.users).length ? (
<ScrollArea w='100%'>
<Table w='120%'>
<Table.Thead>
<Table.Tr>
<Table.Th></Table.Th>
<Table.Th>ID</Table.Th>
<Table.Th>Username</Table.Th>
<Table.Th>Password</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>OAuth Providers</Table.Th>
<Table.Th>Quota</Table.Th>
<Table.Th>Passkeys</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{userRows}</Table.Tbody>
</Table>
</ScrollArea>
) : (
<Center m='sm'>
<b>No users found (how?)</b>
</Center>
)}
</Paper>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='user_oauth_providers'>
<Accordion.Control icon={<IconUsers size='1rem' />}>User OAuth Providers</Accordion.Control>
<Accordion.Panel>
<Paper withBorder>
{Object.keys(export4.data.userOauthProviders).length ? (
<Table.ScrollContainer minWidth={100}>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Provider</Table.Th>
<Table.Th>OAuth Username</Table.Th>
<Table.Th>OAuth ID</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{userOauthProvidersRows}</Table.Tbody>
</Table>
</Table.ScrollContainer>
) : (
<Center m='sm'>
<b>No user oauth providers found</b>
</Center>
)}
</Paper>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='files'>
<Accordion.Control icon={<IconFiles size='1rem' />}>Files</Accordion.Control>
<Accordion.Panel>
<Paper withBorder>
{export4.data.files.length ? (
<Table.ScrollContainer minWidth={100}>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th>Password</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Owner</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{fileRows}</Table.Tbody>
</Table>
</Table.ScrollContainer>
) : (
<Center m='sm'>
<b>No files found</b>
</Center>
)}
</Paper>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='tags'>
<Accordion.Control icon={<IconTag size='1rem' />}>User Tags</Accordion.Control>
<Accordion.Panel>
<Paper withBorder>
{export4.data.userTags.length ? (
<Table.ScrollContainer minWidth={100}>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Files</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{tagsRows}</Table.Tbody>
</Table>
</Table.ScrollContainer>
) : (
<Center m='sm'>
<b>No user tags found</b>
</Center>
)}
</Paper>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='folders'>
<Accordion.Control icon={<IconFolder size='1rem' />}>Folders</Accordion.Control>
<Accordion.Panel>
<Paper withBorder>
{export4.data.folders.length ? (
<Table.ScrollContainer minWidth={100}>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Owner</Table.Th>
<Table.Th>Public</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th>Files</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{folderRows}</Table.Tbody>
</Table>
</Table.ScrollContainer>
) : (
<Center m='sm'>
<b>No folders found</b>
</Center>
)}
</Paper>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='urls'>
<Accordion.Control icon={<IconLink size='1rem' />}>Urls</Accordion.Control>
<Accordion.Panel>
<Paper withBorder>
{export4.data.urls.length ? (
<Table.ScrollContainer minWidth={100}>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Code</Table.Th>
<Table.Th>Owner</Table.Th>
<Table.Th>Destination</Table.Th>
<Table.Th>Vanity</Table.Th>
<Table.Th>Password</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th>Enabled</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{urlRows}</Table.Tbody>
</Table>
</Table.ScrollContainer>
) : (
<Center m='sm'>
<b>No urls found</b>
</Center>
)}
</Paper>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='invites'>
<Accordion.Control icon={<IconTagPlus size='1rem' />}>Invites</Accordion.Control>
<Accordion.Panel>
<Paper withBorder>
{export4.data.invites.length ? (
<Table.ScrollContainer minWidth={100}>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Code</Table.Th>
<Table.Th>Created By</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th>Uses</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{invitesRows}</Table.Tbody>
</Table>
</Table.ScrollContainer>
) : (
<Center m='sm'>
<b>No invites found</b>
</Center>
)}
</Paper>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='metrics'>
<Accordion.Control icon={<IconGraphFilled size='1rem' />}>Metrics</Accordion.Control>
<Accordion.Panel>
<Stack gap={2}>
<TextDetail name='Total Metrics Entries'>{export4.data.metrics.length}</TextDetail>
<Text fw={700} c='dimmed' mb={-10}>
Latest Metrics Entry:
</Text>
<HighlightCode
language='json'
code={JSON.stringify(
export4.data.metrics.sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
)[export4.data.metrics.length - 1],
null,
2,
)}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</>
);
}

View File

@@ -0,0 +1,77 @@
import { Export4 } from '@/lib/import/version4/validateExport';
import { Box, Button, Checkbox, Collapse, Group, Paper, Table, Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
export default function Export4ImportSettings({
export4,
setImportSettings,
importSettings,
}: {
export4: Export4;
setImportSettings: (importSettings: boolean) => void;
importSettings: boolean;
}) {
const [showSettings, { toggle: toggleSettings }] = useDisclosure(false);
const filteredSettings = Object.fromEntries(
Object.entries(export4.data.settings).filter(
([key, _value]) => !['createdAt', 'updatedAt', 'id'].includes(key),
),
);
return (
<Box my='lg'>
<Text size='md'>Import settings?</Text>
<Text size='sm' c='dimmed'>
Import all settings from your previous instance into this v4 instance.
<br />
After importing, it is recommended to restart Zipline for all settings to take full effect.
</Text>
<Button my='xs' onClick={toggleSettings} size='compact-xs'>
{showSettings ? 'Hide' : 'Show'} Settings to be Imported
</Button>
<Collapse in={showSettings}>
<Paper withBorder>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th w={300}>Key</Table.Th>
<Table.Th>Value</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{Object.entries(filteredSettings).map(([key, value]) => (
<Table.Tr key={key}>
<Table.Td ff='monospace'>{key}</Table.Td>
<Table.Td>
<Text c='dimmed' fz='xs' ff='monospace'>
{JSON.stringify(value)}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
<Button my='xs' onClick={toggleSettings} size='compact-xs'>
{showSettings ? 'Hide' : 'Show'} Settings to be Imported
</Button>
</Collapse>
<Checkbox.Card
checked={importSettings}
onClick={() => setImportSettings(!importSettings)}
radius='md'
my='sm'
>
<Group wrap='nowrap' align='flex-start'>
<Checkbox.Indicator m='md' />
<Text my='sm'>Import {Object.keys(filteredSettings).length} settings</Text>
</Group>
</Checkbox.Card>
</Box>
);
}

View File

@@ -0,0 +1,59 @@
import { Export4 } from '@/lib/import/version4/validateExport';
import { Avatar, Box, Group, Radio, Stack, Text } from '@mantine/core';
export default function Export4UserChoose({
export4,
setImportFrom,
importFrom,
}: {
export4: Export4;
setImportFrom: (importFrom: string) => void;
importFrom: string;
}) {
return (
<Box my='lg'>
<Text size='md'>Select a user to import data from into the current user.</Text>
<Text size='sm' c='dimmed'>
This option allows you to import data from a user in your export into the currently logged-in user,
even if both have the same username. Normally, the system skips importing users with usernames that
already exist in the system. <br /> <br /> <b>However</b>, if you&apos;ve just set up your instance
and reused the same username as your old instance, this option enables you to merge data from that
user into your logged-in account without needing to delete or replace it.{' '}
<b>It is recommended to select a user with super-administrator permissions for this operation.</b>
</Text>
<Radio.Group value={importFrom} onChange={(value) => setImportFrom(value)} name='importFrom'>
{export4.data.users.map((user, i) => (
<Radio.Card key={i} value={user.id} my='sm'>
<Group wrap='nowrap' align='flex-start'>
<Radio.Indicator m='md' />
{user.avatar && <Avatar my='md' src={user.avatar} alt={user.username} radius='sm' />}
<Stack gap={0}>
<Text my='sm'>
{user.username} ({user.id})
</Text>{' '}
{user.role === 'SUPERADMIN' && (
<Text c='red' size='xs' mb='xs'>
Super Administrator
</Text>
)}
</Stack>
</Group>
</Radio.Card>
))}
<Radio.Card value='' my='sm'>
<Group wrap='nowrap' align='flex-start'>
<Radio.Indicator m='md' />
<Stack gap={0}>
<Text my='sm'>Do not merge data</Text>{' '}
<Text c='dimmed' size='xs' mb='xs'>
Select this option if you do not want to merge data from any user into the current user.
</Text>
</Stack>
</Group>
</Radio.Card>
</Radio.Group>
</Box>
);
}

View File

@@ -0,0 +1,51 @@
import { Export4 } from '@/lib/import/version4/validateExport';
import { useUserStore } from '@/lib/store/user';
import { Box, Checkbox, Group, Text } from '@mantine/core';
export function detectSameInstance(export4?: Export4 | null, currentUserId?: string) {
if (!export4) return false;
if (!currentUserId) return false;
const idInExport = export4.data.users.find((user) => user.id === currentUserId);
return !!idInExport;
}
export default function Export4WarningSameInstance({
export4,
sameInstanceAgree,
setSameInstanceAgree,
}: {
export4: Export4;
sameInstanceAgree: boolean;
setSameInstanceAgree: (sameInstanceAgree: boolean) => void;
}) {
const currentUserId = useUserStore((state) => state.user?.id);
const isSameInstance = detectSameInstance(export4, currentUserId);
if (!isSameInstance) return null;
return (
<Box my='lg'>
<Text size='md' c='red'>
Same Instance Detected
</Text>
<Text size='sm' c='dimmed'>
Detected that you are importing data from the same instance as the current running one. Proceeding
with this import may lead to data conflicts or overwriting existing data. Please ensure that you
understand the implications before continuing.
</Text>
<Checkbox.Card
checked={sameInstanceAgree}
onClick={() => setSameInstanceAgree(!sameInstanceAgree)}
radius='md'
my='sm'
>
<Group wrap='nowrap' align='flex-start'>
<Checkbox.Indicator m='md' />
<Text my='sm'>I agree, and understand the implications.</Text>
</Group>
</Checkbox.Card>
</Box>
);
}

View File

@@ -0,0 +1,277 @@
import { Export4, validateExport } from '@/lib/import/version4/validateExport';
import { Button, FileButton, Modal, Pill, Text } from '@mantine/core';
import { showNotification, updateNotification } from '@mantine/notifications';
import { IconDatabaseImport, IconDatabaseOff, IconUpload, IconX } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import Export4Details from './Export4Details';
import Export4ImportSettings from './Export4ImportSettings';
import Export4WarningSameInstance, { detectSameInstance } from './Export4WarningSameInstance';
import Export4UserChoose from './Export4UserChoose';
import { useUserStore } from '@/lib/store/user';
import { modals } from '@mantine/modals';
import { fetchApi } from '@/lib/fetchApi';
import { Response } from '@/lib/api/response';
import { mutate } from 'swr';
export default function ImportV4Button() {
const [open, setOpen] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [export4, setExport4] = useState<Export4 | null>(null);
const [importSettings, setImportSettings] = useState(true);
const [sameInstanceAgree, setSameInstanceAgree] = useState(false);
const [importFrom, setImportFrom] = useState('');
const currentUserId = useUserStore((state) => state.user?.id);
const isSameInstance = detectSameInstance(export4, currentUserId);
const onContent = (content: string) => {
if (!content) return console.error('no content');
try {
const data = JSON.parse(content);
onJson(data);
} catch (error) {
console.error('failed to parse file content', error);
}
};
const onJson = (data: unknown) => {
const validated = validateExport(data);
if (!validated.success) {
console.error('Failed to validate import data', validated);
showNotification({
title: 'There were errors with the import',
message:
"Zipline couldn't validate the import data. Are you sure it's a valid export from Zipline v4? For more details about the error, check the browser console.",
color: 'red',
icon: <IconDatabaseOff size='1rem' />,
autoClose: 10000,
});
setOpen(false);
setFile(null);
return;
}
setExport4(validated.data);
};
const handleImportSettings = async () => {
if (!export4) return;
const { error } = await fetchApi<Response['/api/server/settings']>(
'/api/server/settings',
'PATCH',
export4.data.settings,
);
if (error) {
showNotification({
title: 'Failed to import settings',
message: error.issues
? error.issues.map((x: { message: string }) => x.message).join('\n')
: error.error,
color: 'red',
});
} else {
showNotification({
title: 'Settings imported',
message: 'To ensure that all settings take effect, it is recommended to restart Zipline.',
color: 'green',
});
mutate('/api/server/settings');
mutate('/api/server/settings/web');
mutate('/api/server/public');
}
};
const handleImport = async () => {
if (!export4) return;
if (isSameInstance && !sameInstanceAgree) {
modals.openContextModal({
modal: 'alert',
title: 'Same Instance Detected',
innerProps: {
modalBody:
'Detected that you are importing data from the same instance as the current running one. You must agree to the warning before proceeding with the import.',
},
});
return;
}
modals.openConfirmModal({
title: 'Are you sure?',
children:
'This process will NOT overwrite existing data but will append to it. In case of conflicts, the imported data will be skipped and logged.',
labels: {
confirm: 'Yes, import data.',
cancel: 'Cancel',
},
onConfirm: async () => {
showNotification({
title: 'Importing data...',
message:
'The export file will be uploaded. This amy take a few moments. The import is running in the background and is logged, so you can close this browser tab if you want.',
color: 'blue',
autoClose: 5000,
id: 'importing-data',
loading: true,
});
setOpen(false);
await handleImportSettings();
const { error, data } = await fetchApi<Response['/api/server/import/v4']>(
'/api/server/import/v4',
'POST',
{
export4,
config: {
settings: importSettings,
mergeCurrentUser: importFrom === '' ? undefined : importFrom,
},
},
);
if (error) {
updateNotification({
title: 'Failed to import data...',
message:
error.error ?? 'An error occurred while importing data. Check the logs for more details.',
color: 'red',
icon: <IconDatabaseOff size='1rem' />,
id: 'importing-data',
autoClose: 10000,
});
} else {
if (!data) return;
modals.open({
title: 'Import Completed.',
children: (
<Text size='md'>
The import has been completed. To make sure files are properly viewable, make sure that you
have configured the datasource correctly to match your previous instance. For example, if you
were using local storage before, make sure to set it to the same directory (or same backed up
directory) as before. If you are using S3, make sure you are using the same bucket. <br />{' '}
<br />
Additionally, it is recommended to restart Zipline to ensure all settings take full effect.
<br /> <br />
<b>Users: </b>
{data.imported.users} imported.
<br />
<b>OAuth Providers: </b>
{data.imported.oauthProviders} imported.
<br />
<b>Quotas: </b>
{data.imported.quotas} imported.
<br />
<b>Passkeys: </b>
{data.imported.passkeys} imported.
<br />
<b>Folders: </b>
{data.imported.folders} imported.
<br />
<b>Files: </b>
{data.imported.files} imported.
<br />
<b>Tags: </b>
{data.imported.tags} imported.
<br />
<b>URLs: </b>
{data.imported.urls} imported.
<br />
<b>Invites: </b>
{data.imported.invites} imported.
<br />
<b>Metrics: </b>
{data.imported.metrics} imported.
</Text>
),
});
}
},
});
setFile(null);
setExport4(null);
};
useEffect(() => {
if (!open) return;
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result;
onContent(content as string);
};
reader.readAsText(file);
}, [file]);
return (
<>
<Modal opened={open} onClose={() => setOpen(false)} title='Import V4 Data' size='xl'>
{export4 ? (
<Button
onClick={() => {
setFile(null);
setExport4(null);
}}
color='red'
variant='filled'
aria-label='Clear'
mb='xs'
leftSection={<IconX size='1rem' />}
fullWidth
>
Clear Import
</Button>
) : (
<FileButton onChange={setFile} accept='application/json'>
{(props) => (
<>
<Button
{...props}
disabled={!!file}
mb='xs'
leftSection={<IconUpload size='1rem' />}
fullWidth
>
Upload Export (JSON)
</Button>
</>
)}
</FileButton>
)}
{file && export4 && (
<>
<Export4Details export4={export4} />
<Export4ImportSettings
export4={export4}
importSettings={importSettings}
setImportSettings={setImportSettings}
/>
<Export4UserChoose export4={export4} importFrom={importFrom} setImportFrom={setImportFrom} />
<Export4WarningSameInstance
export4={export4}
sameInstanceAgree={sameInstanceAgree}
setSameInstanceAgree={setSameInstanceAgree}
/>
</>
)}
{export4 && (
<Button onClick={handleImport} fullWidth leftSection={<IconDatabaseImport size='1rem' />} mt='xs'>
Import Data
</Button>
)}
</Modal>
<Button size='xl' rightSection={<Pill>V4</Pill>} onClick={() => setOpen(true)}>
Import
</Button>
</>
);
}

View File

@@ -0,0 +1,28 @@
import { Divider, Group, Modal } from '@mantine/core';
import { useState } from 'react';
import ImportV3Button from './ImportV3Button';
import ImportV4Button from './ImportV4Button';
import ExportButton from './ExportButton';
import ActionButton from '../../ActionButton';
import { IconDatabasePlus } from '@tabler/icons-react';
export default function ImportExport() {
const [open, setOpen] = useState(false);
return (
<>
<Modal opened={open} onClose={() => setOpen(false)} size='lg' title='Import / Export Data'>
<Group gap='sm' grow>
<ImportV3Button />
<ImportV4Button />
</Group>
<Divider my='md' />
<ExportButton />
</Modal>
<ActionButton onClick={() => setOpen(true)} Icon={IconDatabasePlus} />
</>
);
}

View File

@@ -4,6 +4,7 @@ import { Button, Group, Modal, Stack, Switch } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconFileSearch } from '@tabler/icons-react';
import { useState } from 'react';
import ActionButton from '../ActionButton';
export default function RequerySizeButton() {
const [forceUpdate, setForceUpdate] = useState(false);
@@ -65,9 +66,8 @@ export default function RequerySizeButton() {
</Button>
</Group>
</Modal>
<Button size='sm' leftSection={<IconFileSearch size='1rem' />} onClick={() => setOpen(true)}>
Requery Size of Files
</Button>
<ActionButton onClick={() => setOpen(true)} />
</>
);
}

View File

@@ -0,0 +1,61 @@
import { Group, Paper, Stack, Text, Title } from '@mantine/core';
import ClearTempButton from './actions/ClearTempButton';
import ClearZerosButton from './actions/ClearZerosButton';
import GenThumbsButton from './actions/GenThumbsButton';
import ImportExport from './actions/ImportExportButton';
import RequerySizeButton from './actions/RequerySizeButton';
const ACTIONS = [
{
name: 'Import/Export Data',
desc: 'Allows you to import or export server data and configurations.',
Component: ImportExport,
},
{
name: 'Clear Temporary Files',
desc: 'Removes all temporary files from the temporary directory.',
Component: ClearTempButton,
},
{
name: 'Clear Zero Byte Files',
desc: 'Deletes all files with zero bytes from the database and/or storage.',
Component: ClearZerosButton,
},
{
name: 'Requery File Sizes',
desc: 'Recalculates and updates the sizes of all files in the database.',
Component: RequerySizeButton,
},
{
name: 'Generate Thumbnails',
desc: 'Creates thumbnails for all image and video files that lack them.',
Component: GenThumbsButton,
},
];
export default function DashboardServerActions() {
return (
<>
<Group gap='sm'>
<Title order={1}>Server Actions</Title>
</Group>
<Text c='dimmed' mb='xs'>
Useful tools and scripts for server management.
</Text>
<Stack gap='xs' my='sm'>
{ACTIONS.map(({ name, desc, Component }) => (
<Paper withBorder p='sm' key={name}>
<Group gap='md'>
<Component />
<div>
<Title order={4}>{name}</Title>
<Text c='dimmed'>{desc}</Text>
</div>
</Group>
</Paper>
))}
</Stack>
</>
);
}

View File

@@ -32,7 +32,6 @@ export default function DashboardServerSettings() {
const scrollToSetting = useMemo(() => {
return (setting: string) => {
console.log('scrolling to setting:', setting);
const input = document.querySelector<HTMLInputElement>(`[data-path="${setting}"]`);
if (input) {
const observer = new IntersectionObserver(

View File

@@ -17,11 +17,13 @@ export default function Core({
coreReturnHttpsUrls: boolean;
coreDefaultDomain: string | null | undefined;
coreTempDirectory: string;
coreTrustProxy: boolean;
}>({
initialValues: {
coreReturnHttpsUrls: false,
coreDefaultDomain: '',
coreTempDirectory: '/tmp/zipline',
coreTrustProxy: false,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
@@ -45,6 +47,7 @@ export default function Core({
coreReturnHttpsUrls: data.settings.coreReturnHttpsUrls ?? false,
coreDefaultDomain: data.settings.coreDefaultDomain ?? '',
coreTempDirectory: data.settings.coreTempDirectory ?? '/tmp/zipline',
coreTrustProxy: data.settings.coreTrustProxy ?? false,
});
}, [data]);
@@ -55,14 +58,20 @@ export default function Core({
<Title order={2}>Core</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<Switch
mt='md'
label='Return HTTPS URLs'
description='Return URLs with HTTPS protocol.'
{...form.getInputProps('coreReturnHttpsUrls', { type: 'checkbox' })}
/>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<Switch
mt='md'
label='Return HTTPS URLs'
description='Return URLs with HTTPS protocol.'
{...form.getInputProps('coreReturnHttpsUrls', { type: 'checkbox' })}
/>
<Switch
label='Trust Proxies'
description='Trust the X-Forwarded-* headers set by proxies. Only enable this if you are behind a trusted proxy (nginx, caddy, etc.). Requires a server restart.'
{...form.getInputProps('coreTrustProxy', { type: 'checkbox' })}
/>
<TextInput
label='Default Domain'
description='The domain to use when generating URLs. This value should not include the protocol.'

View File

@@ -1,125 +1,111 @@
import { Response } from '@/lib/api/response';
import { Button, Group, LoadingOverlay, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
import { ActionIcon, Group, LoadingOverlay, Paper, Table, Text, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconPlus, IconTrash } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
const DOMAIN_REGEX =
/^[a-zA-Z0-9][a-zA-Z0-9-_]{0,61}[a-zA-Z0-9]{0,1}\.([a-zA-Z]{1,6}|[a-zA-Z0-9-]{1,30}\.[a-zA-Z]{2,3})$/gim;
export default function Domains({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
swr: {
data: Response['/api/server/settings'] | undefined;
isLoading: boolean;
};
}) {
const navigate = useNavigate();
const [submitting, setSubmitting] = useState(false);
const [domains, setDomains] = useState<string[]>([]);
const form = useForm({
initialValues: {
newDomain: '',
},
// using 'domains' here so that settingsOnSubmit picks up errors correctly
initialValues: { domains: '' },
});
const onSubmit = settingsOnSubmit(navigate, form);
const submitSettings = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;
const domainsData = Array.isArray(data.settings.domains)
? data.settings.domains.map((d) => String(d))
: [];
setDomains(domainsData);
}, [data]);
const domains = Array.isArray(data?.settings.domains) ? data!.settings.domains.map(String) : [];
const addDomain = () => {
const { newDomain } = form.values;
if (!newDomain) return;
async function updateDomains(nextDomains: string[]) {
setSubmitting(true);
if (!DOMAIN_REGEX.test(newDomain)) {
return form.setFieldError('newDomain', 'Invalid Domain');
try {
const error = await submitSettings({ domains: nextDomains });
if (!error) form.setFieldValue('domains', '');
} catch (err: any) {
form.setFieldError('domains', err?.message ?? err?.error ?? 'Failed to update domains');
} finally {
setSubmitting(false);
}
}
const updatedDomains = [...domains, newDomain.trim()];
setDomains(updatedDomains);
form.setValues({ newDomain: '' });
onSubmit({ domains: updatedDomains });
const addDomain = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const domain = form.values.domains.trim();
if (!domain) return;
if (domains.includes(domain)) return form.setFieldError('domains', 'This domain already exists');
await updateDomains([...domains, domain]);
};
const removeDomain = (index: number) => {
const updatedDomains = domains.filter((_, i) => i !== index);
setDomains(updatedDomains);
onSubmit({ domains: updatedDomains });
const removeDomain = async (domain: string) => {
await updateDomains(domains.filter((d) => d !== domain));
};
return (
<Paper withBorder p='sm' pos='relative'>
<LoadingOverlay visible={isLoading} />
<LoadingOverlay visible={isLoading || submitting} />
<Title order={2}>Domains</Title>
<Group mt='md' align='flex-end'>
<TextInput
label='Domain'
description='Enter a domain name (e.g. example.com)'
placeholder='example.com'
{...form.getInputProps('newDomain')}
/>
<Button onClick={addDomain} leftSection={<IconPlus size='1rem' />}>
Add Domain
</Button>
</Group>
<form onSubmit={addDomain}>
<Group mt='md' align='flex-end'>
<TextInput
description='Enter a domain name'
placeholder='example.com'
flex={1}
{...form.getInputProps('domains')}
/>
<ActionIcon type='submit' color='blue' size='lg' variant='filled' disabled={submitting}>
<IconPlus size='1.25rem' />
</ActionIcon>
</Group>
</form>
<SimpleGrid mt='md' cols={{ base: 1, sm: 2, md: 3 }} spacing='md' verticalSpacing='md'>
{domains.map((domain, index) => (
<Paper
key={index}
withBorder
p='md'
radius='md'
shadow='xs'
style={{
background: 'rgba(0,0,0,0.03)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
minHeight: 64,
}}
>
<Group justify='space-between' align='center' wrap='nowrap'>
<div
style={{
minWidth: 0,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: 500,
fontSize: 16,
}}
>
{domain}
</div>
<Button
variant='subtle'
color='red'
size='xs'
onClick={() => removeDomain(index)}
px={8}
style={{
aspectRatio: '1/1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IconTrash size='1rem' />
</Button>
</Group>
</Paper>
))}
</SimpleGrid>
{domains.length > 0 ? (
<Paper withBorder p={0} mt='md'>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Domain</Table.Th>
<Table.Th w={30}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{domains.map((domain) => (
<Table.Tr key={domain}>
<Table.Td>
<Text fw={500} truncate>
{domain}
</Text>
</Table.Td>
<Table.Td>
<ActionIcon color='red' onClick={() => removeDomain(domain)} disabled={submitting}>
<IconTrash size='1.25rem' />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
) : (
<Text mt='md' c='dimmed'>
No domains added yet.
</Text>
)}
</Paper>
);
}

View File

@@ -5,6 +5,7 @@ import {
LoadingOverlay,
NumberInput,
Paper,
Select,
SimpleGrid,
Switch,
TextInput,
@@ -33,6 +34,7 @@ export default function Features({
featuresDeleteOnMaxViews: true,
featuresThumbnailsEnabled: true,
featuresThumbnailsNumberThreads: 4,
featuresThumbnailsFormat: 'jpg',
featuresMetricsEnabled: true,
featuresMetricsAdminOnly: false,
featuresMetricsShowUserSpecific: true,
@@ -58,6 +60,7 @@ export default function Features({
featuresDeleteOnMaxViews: data.settings.featuresDeleteOnMaxViews ?? true,
featuresThumbnailsEnabled: data.settings.featuresThumbnailsEnabled ?? true,
featuresThumbnailsNumberThreads: data.settings.featuresThumbnailsNumberThreads ?? 4,
featuresThumbnailsFormat: data.settings.featuresThumbnailsFormat ?? 'jpg',
featuresMetricsEnabled: data.settings.featuresMetricsEnabled ?? true,
featuresMetricsAdminOnly: data.settings.featuresMetricsAdminOnly ?? false,
featuresMetricsShowUserSpecific: data.settings.featuresMetricsShowUserSpecific ?? true,
@@ -82,7 +85,7 @@ export default function Features({
<Switch
label='/robots.txt'
description='Enables a robots.txt file for search engine optimization. Requires a server restart.'
description='Enables a /robots.txt to stop search crawlers. Requires a server restart.'
{...form.getInputProps('featuresRobotsTxt', { type: 'checkbox' })}
/>
@@ -143,6 +146,19 @@ export default function Features({
{...form.getInputProps('featuresThumbnailsNumberThreads')}
/>
<Select
label='Thumbnails Format'
description='The output format for thumbnails. Requires a server restart.'
data={[
{ value: 'jpg', label: '.jpg' },
{ value: 'png', label: '.png' },
{ value: 'webp', label: '.webp' },
]}
{...form.getInputProps('featuresThumbnailsFormat')}
/>
<div />
<Switch
label='Version Checking'
description='Enable version checking for the server. This will check for updates and display the status on the sidebar to all users.'

View File

@@ -30,11 +30,13 @@ export default function Files({
filesDisabledExtensions: string;
filesMaxFileSize: string;
filesDefaultExpiration: string | null;
filesMaxExpiration: string | null;
filesAssumeMimetypes: boolean;
filesDefaultDateFormat: string;
filesRemoveGpsMetadata: boolean;
filesRandomWordsNumAdjectives: number;
filesRandomWordsSeparator: string;
filesDefaultCompressionFormat: string;
}>({
initialValues: {
filesRoute: '/u',
@@ -43,11 +45,13 @@ export default function Files({
filesDisabledExtensions: '',
filesMaxFileSize: '100mb',
filesDefaultExpiration: '',
filesMaxExpiration: '',
filesAssumeMimetypes: false,
filesDefaultDateFormat: 'YYYY-MM-DD_HH:mm:ss',
filesRemoveGpsMetadata: false,
filesRandomWordsNumAdjectives: 3,
filesRandomWordsSeparator: '-',
filesDefaultCompressionFormat: 'jpg',
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
@@ -61,6 +65,12 @@ export default function Files({
values.filesDefaultExpiration = values.filesDefaultExpiration.trim();
}
if (values.filesMaxExpiration?.trim() === '' || !values.filesMaxExpiration) {
values.filesMaxExpiration = null;
} else {
values.filesMaxExpiration = values.filesMaxExpiration.trim();
}
if (!values.filesDisabledExtensions) {
// @ts-ignore
values.filesDisabledExtensions = [];
@@ -93,11 +103,13 @@ export default function Files({
filesDisabledExtensions: data.settings.filesDisabledExtensions.join(', ') ?? '',
filesMaxFileSize: data.settings.filesMaxFileSize ?? '100mb',
filesDefaultExpiration: data.settings.filesDefaultExpiration ?? '',
filesMaxExpiration: data.settings.filesMaxExpiration ?? '',
filesAssumeMimetypes: data.settings.filesAssumeMimetypes ?? false,
filesDefaultDateFormat: data.settings.filesDefaultDateFormat ?? 'YYYY-MM-DD_HH:mm:ss',
filesRemoveGpsMetadata: data.settings.filesRemoveGpsMetadata ?? false,
filesRandomWordsNumAdjectives: data.settings.filesRandomWordsNumAdjectives ?? 3,
filesRandomWordsSeparator: data.settings.filesRandomWordsSeparator ?? '-',
filesDefaultCompressionFormat: data.settings.filesDefaultCompressionFormat ?? 'jpg',
});
}, [data]);
@@ -158,6 +170,13 @@ export default function Files({
{...form.getInputProps('filesMaxFileSize')}
/>
<TextInput
label='Default Date Format'
description='The default date format to use.'
placeholder='YYYY-MM-DD_HH:mm:ss'
{...form.getInputProps('filesDefaultDateFormat')}
/>
<TextInput
label='Default Expiration'
description='The default expiration time for files.'
@@ -166,10 +185,10 @@ export default function Files({
/>
<TextInput
label='Default Date Format'
description='The default date format to use.'
placeholder='YYYY-MM-DD_HH:mm:ss'
{...form.getInputProps('filesDefaultDateFormat')}
label='Max Expiration'
description='The maximum expiration time allowed for files.'
placeholder='365d'
{...form.getInputProps('filesMaxExpiration')}
/>
<NumberInput
@@ -186,6 +205,19 @@ export default function Files({
placeholder='-'
{...form.getInputProps('filesRandomWordsSeparator')}
/>
<Select
label='Default Compression Format'
description='The default image compression format to use when only a compression percent is specified.'
placeholder='jpg'
data={[
{ value: 'jpg', label: '.jpg' },
{ value: 'png', label: '.png' },
{ value: 'webp', label: '.webp' },
{ value: 'jxl', label: '.jxl' },
]}
{...form.getInputProps('filesDefaultCompressionFormat')}
/>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>

View File

@@ -17,7 +17,9 @@ export default function Mfa({
initialValues: {
mfaTotpEnabled: false,
mfaTotpIssuer: 'Zipline',
mfaPasskeys: false,
mfaPasskeysEnabled: false,
mfaPasskeysRpID: '',
mfaPasskeysOrigin: '',
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
@@ -32,7 +34,9 @@ export default function Mfa({
form.setValues({
mfaTotpEnabled: data.settings.mfaTotpEnabled ?? false,
mfaTotpIssuer: data.settings.mfaTotpIssuer ?? 'Zipline',
mfaPasskeys: data.settings.mfaPasskeys,
mfaPasskeysEnabled: data.settings.mfaPasskeysEnabled ?? false,
mfaPasskeysRpID: data.settings.mfaPasskeysRpID ?? '',
mfaPasskeysOrigin: data.settings.mfaPasskeysOrigin ?? '',
});
}, [data]);
@@ -47,7 +51,21 @@ export default function Mfa({
<Switch
label='Passkeys'
description='Enable the use of passwordless login with the use of WebAuthn passkeys like your phone, security keys, etc.'
{...form.getInputProps('mfaPasskeys', { type: 'checkbox' })}
{...form.getInputProps('mfaPasskeysEnabled', { type: 'checkbox' })}
/>
<TextInput
label='Relying Party ID'
description='The Relying Party ID (RP ID) to use for WebAuthn passkeys.'
placeholder='example.com'
{...form.getInputProps('mfaPasskeysRpID')}
/>
<TextInput
label='Origin'
description='The Origin to use for WebAuthn passkeys.'
placeholder='https://example.com'
{...form.getInputProps('mfaPasskeysOrigin')}
/>
<Switch

View File

@@ -20,6 +20,7 @@ export default function Tasks({
tasksMaxViewsInterval: '30m',
tasksThumbnailsInterval: '30m',
tasksMetricsInterval: '30m',
tasksCleanThumbnailsInterval: '1d',
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
@@ -37,6 +38,7 @@ export default function Tasks({
tasksMaxViewsInterval: data.settings.tasksMaxViewsInterval ?? '30m',
tasksThumbnailsInterval: data.settings.tasksThumbnailsInterval ?? '30m',
tasksMetricsInterval: data.settings.tasksMetricsInterval ?? '30m',
tasksCleanThumbnailsInterval: data.settings.tasksCleanThumbnailsInterval ?? '1d',
});
}, [data]);
@@ -79,6 +81,13 @@ export default function Tasks({
placeholder='30m'
{...form.getInputProps('tasksThumbnailsInterval')}
/>
<TextInput
label='Clean Thumbnails Interval'
description='How often to check and delete orphaned thumbnails from the filesystem or database.'
placeholder='1d'
{...form.getInputProps('tasksCleanThumbnailsInterval')}
/>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>

View File

@@ -32,6 +32,8 @@ export function settingsOnSubmit(navigate: NavigateFunction, form: ReturnType<ty
}
}
}
return error;
} else {
showNotification({
message: 'Settings saved',
@@ -39,7 +41,6 @@ export function settingsOnSubmit(navigate: NavigateFunction, form: ReturnType<ty
icon: <IconDeviceFloppy size='1rem' />,
});
await fetch('/reload');
mutate('/api/server/settings', data);
mutate('/api/server/settings/web');
mutate('/api/server/public');

View File

@@ -1,7 +1,5 @@
import { useConfig } from '@/components/ConfigProvider';
import { eitherTrue } from '@/lib/primitive';
import { isAdministrator } from '@/lib/role';
import { useUserStore } from '@/lib/store/user';
import { Group, SimpleGrid, Stack, Title } from '@mantine/core';
import { lazy } from 'react';
@@ -10,7 +8,6 @@ const SettingsDashboard = lazy(() => import('./parts/SettingsDashboard'));
const SettingsFileView = lazy(() => import('./parts/SettingsFileView'));
const SettingsGenerators = lazy(() => import('./parts/SettingsGenerators'));
const SettingsMfa = lazy(() => import('./parts/SettingsMfa'));
const SettingsServerActions = lazy(() => import('./parts/SettingsServerUtil'));
const SettingsUser = lazy(() => import('./parts/SettingsUser'));
const SettingsExports = lazy(() => import('./parts/SettingsExports'));
const SettingsSessions = lazy(() => import('./parts/SettingsSessions'));
@@ -18,9 +15,6 @@ const SettingsOAuth = lazy(() => import('./parts/SettingsOAuth'));
export default function DashboardSettings() {
const config = useConfig();
const user = useUserStore((state) => state.user);
console.log(config.oauthEnabled);
return (
<>
@@ -51,8 +45,6 @@ export default function DashboardSettings() {
<SettingsExports />
<SettingsGenerators />
{isAdministrator(user?.role) && <SettingsServerActions />}
</SimpleGrid>
</>
);

View File

@@ -27,6 +27,7 @@ import {
IconDeviceFloppy,
IconFileX,
} from '@tabler/icons-react';
import { useEffect } from 'react';
import { mutate } from 'swr';
import { useShallow } from 'zustand/shallow';
@@ -94,6 +95,24 @@ export default function SettingsFileView() {
});
};
useEffect(() => {
if (user) {
form.setValues({
enabled: user.view.enabled || false,
content: user.view.content || '',
embed: user.view.embed || false,
embedTitle: user.view.embedTitle || '',
embedDescription: user.view.embedDescription || '',
embedSiteName: user.view.embedSiteName || '',
embedColor: user.view.embedColor || '',
align: user.view.align || 'left',
showMimetype: user.view.showMimetype || false,
showTags: user.view.showTags || false,
showFolder: user.view.showFolder || false,
});
}
}, [user]);
return (
<Paper withBorder p='sm'>
<Title order={2}>Viewing Files</Title>

View File

@@ -104,14 +104,12 @@ export default function GeneratorButton({
);
const { data: tokenData, isLoading, error } = useSWR<Response['/api/user/token']>('/api/user/token');
const { data: settingsData } = useSWR<Response['/api/server/settings']>('/api/server/settings');
const { data: settingsData } = useSWR<Response['/api/server/public']>('/api/server/public');
const isUnixLike = name === 'Flameshot' || name === 'Shell Script';
const onlyFile = generatorType === 'file';
const domains = Array.isArray(settingsData?.settings.domains)
? settingsData?.settings.domains.map((d) => String(d))
: [];
const domains = Array.isArray(settingsData?.domains) ? settingsData?.domains.map((d) => String(d)) : [];
const domainOptions = [
{ value: '', label: 'Default Domain' },
...domains.map((domain) => ({
@@ -232,7 +230,7 @@ export default function GeneratorButton({
{name === 'ShareX' && (
<Switch
label='Xshare Compatibility'
description='If you choose to use the Xshare app on Android, enable this option for compatibility. The genereated config will not work with ShareX.'
description='If you choose to use the Xshare app on Android, enable this option for compatibility. The generated config will not work with ShareX.'
checked={options.sharex_xshareCompatibility ?? false}
onChange={(event) => setOption({ sharex_xshareCompatibility: event.currentTarget.checked })}
disabled={!onlyFile}

View File

@@ -53,7 +53,7 @@ export default function SettingsGenerators() {
width={24}
height={24}
alt='ishare logo'
src='https://isharemac.app/ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png'
src='https://raw.githubusercontent.com/itoolio/ishare/refs/tags/v4.2.5/ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png'
/>
}
desc={

View File

@@ -1,12 +1,15 @@
import RelativeDate from '@/components/RelativeDate';
import { fetchApi } from '@/lib/fetchApi';
import { registerWeb } from '@/lib/passkey';
import { useUserStore } from '@/lib/store/user';
import { RegistrationResponseJSON } from '@github/webauthn-json/dist/types/browser-ponyfill';
import { UserPasskey } from '@/prisma/client';
import { ActionIcon, Button, Group, Modal, Paper, Stack, Text, TextInput } from '@mantine/core';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import { UserPasskey } from '@/prisma/client';
import {
PublicKeyCredentialCreationOptionsJSON,
RegistrationResponseJSON,
startRegistration,
} from '@simplewebauthn/browser';
import { IconKey, IconKeyOff, IconTrashFilled } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { mutate } from 'swr';
@@ -23,10 +26,15 @@ export default function PasskeyButton() {
const handleRegisterPasskey = async () => {
try {
const { data } = await fetchApi<PublicKeyCredentialCreationOptionsJSON>(
'/api/user/mfa/passkey/options',
'GET',
);
setPasskeyLoading(true);
const res = await registerWeb(user!);
const res = await startRegistration({ optionsJSON: data! });
setNamerShown(true);
setSavedKey(res.toJSON());
setSavedKey(res);
} catch (e: any) {
setPasskeyError(e.message ?? 'An error occurred while creating a passkey');
setPasskeyLoading(false);
@@ -38,7 +46,7 @@ export default function PasskeyButton() {
if (!savedKey) return;
const { error } = await fetchApi('/api/user/mfa/passkey', 'POST', {
reg: savedKey,
response: savedKey,
name: name.trim(),
});

View File

@@ -15,6 +15,7 @@ import {
Stack,
Text,
} from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconShieldLockFilled } from '@tabler/icons-react';
import { useState } from 'react';
@@ -23,13 +24,14 @@ import useSWR, { mutate } from 'swr';
import { useShallow } from 'zustand/shallow';
export default function TwoFAButton() {
const size = useMediaQuery('(max-width: 600px)') ? 'sm' : 'xl';
const [user, setUser] = useUserStore(useShallow((state) => [state.user, state.setUser]));
const [totpOpen, setTotpOpen] = useState(false);
const {
data: twoData,
error: twoError,
isLoading: twoLoading,
data: mfaData,
error: mfaError,
isLoading: mfaLoading,
} = useSWR<Extract<Response['/api/user/mfa/totp'], { secret: string; qrcode: string }>>(
totpOpen && !user?.totpSecret ? '/api/user/mfa/totp' : null,
null,
@@ -51,7 +53,7 @@ export default function TwoFAButton() {
'POST',
{
code: pin,
secret: twoData!.secret,
secret: mfaData!.secret,
},
);
@@ -140,7 +142,7 @@ export default function TwoFAButton() {
>
Google Authenticator
</Anchor>
, and{' '}
,{' '}
<Anchor
component={Link}
to='https://www.microsoft.com/en-us/security/mobile-authenticator-app'
@@ -148,6 +150,14 @@ export default function TwoFAButton() {
>
Microsoft Authenticator
</Anchor>
, and{' '}
<Anchor
component={Link}
to='https://support.apple.com/guide/iphone/automatically-fill-in-verification-codes-ipha6173c19f/ios'
target='_blank'
>
Apple Passwords
</Anchor>
.
</Text>
@@ -156,25 +166,20 @@ export default function TwoFAButton() {
</Text>
<Box pos='relative'>
{twoLoading && !twoError ? (
{mfaLoading && !mfaError ? (
<Box w={180} h={180}>
<LoadingOverlay visible pos='relative' />
</Box>
) : (
<Center>
<Image
width={180}
height={180}
src={twoData?.qrcode}
alt={'qr code ' + twoData?.secret}
/>
<Image h={180} w={180} src={mfaData?.qrcode} alt={'qr code ' + mfaData?.secret} />
</Center>
)}
</Box>
<Text size='sm' c='dimmed'>
If you can&apos;t scan the QR code, you can manually enter the following code into your
authenticator app: <Code>{twoData?.secret ?? ''}</Code>
authenticator app: <Code>{mfaData?.secret ?? ''}</Code>
</Text>
<Text size='sm' c='dimmed'>
@@ -194,7 +199,7 @@ export default function TwoFAButton() {
autoFocus={true}
error={!!pinError}
disabled={pinDisabled}
size='xl'
size={size}
/>
</Center>
{pinError && (

View File

@@ -15,7 +15,7 @@ export default function SettingsMfa() {
<Group mt='xs'>
{config.mfa.totp.enabled && <TwoFAButton />}
{config.mfa.passkeys && <PasskeyButton />}
{config.mfa.passkeys.enabled && <PasskeyButton />}
</Group>
</Paper>
);

View File

@@ -1,25 +0,0 @@
import { Group, Paper, Text, Title } from '@mantine/core';
import ClearTempButton from './ClearTempButton';
import ClearZerosButton from './ClearZerosButton';
import GenThumbsButton from './GenThumbsButton';
import RequerySizeButton from './RequerySizeButton';
import ImportButton from './ImportButton';
export default function SettingsServerActions() {
return (
<Paper withBorder p='sm'>
<Title order={2}>Server Actions</Title>
<Text size='sm' c='dimmed' mt={3}>
Helpful scripts and tools for server management.
</Text>
<Group mt='xs'>
<ClearZerosButton />
<ClearTempButton />
<RequerySizeButton />
<GenThumbsButton />
<ImportButton />
</Group>
</Paper>
);
}

View File

@@ -1,4 +1,3 @@
import { useConfig } from '@/components/ConfigProvider';
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { useUserStore } from '@/lib/store/user';
@@ -25,14 +24,11 @@ import {
IconUser,
IconUserCancel,
} from '@tabler/icons-react';
import { lazy, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { mutate } from 'swr';
import { useShallow } from 'zustand/shallow';
const SettingsAvatar = lazy(() => import('./SettingsAvatar'));
export default function SettingsUser() {
const config = useConfig();
const [user, setUser] = useUserStore(useShallow((state) => [state.user, state.setUser]));
const [tokenShown, setTokenShown] = useState(false);

View File

@@ -147,6 +147,7 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
my='sm'
loading={dropLoading}
disabled={dropLoading}
style={{ zIndex: 1 }}
>
<Group justify='center' gap='xl' style={{ minHeight: rem(220), pointerEvents: 'none' }}>
<Dropzone.Accept>

View File

@@ -34,6 +34,14 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
import { useShallow } from 'zustand/shallow';
import ms from 'ms';
function checkDomains(domains?: unknown): string[] {
if (!domains) return [];
if (!Array.isArray(domains)) return [];
return domains;
}
export default function UploadOptionsButton({ folder, numFiles }: { folder?: string; numFiles: number }) {
const config = useConfig();
@@ -61,12 +69,13 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
'/api/user/folders?noincl=true',
);
const { data: settingsData } = useSWR<Response['/api/server/settings']>('/api/server/settings');
const { data: settingsData } = useSWR<Response['/api/server/public']>('/api/server/public');
const combobox = useCombobox();
const [folderSearch, setFolderSearch] = useState('');
const domains = Array.isArray(settingsData?.settings.domains) ? settingsData.settings.domains : [];
const domains = checkDomains(settingsData?.domains);
const domainOptions = [
{ value: '', label: 'Default Domain' },
...domains.map((domain) => ({
@@ -93,42 +102,65 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
<Stack gap='xs' my='sm'>
<Select
data={[
{ value: 'default', label: `Default (${config.files.defaultExpiration ?? 'never'})` },
{ value: 'never', label: 'Never' },
{ value: '5min', label: '5 minutes' },
{ value: '10min', label: '10 minutes' },
{ value: '15min', label: '15 minutes' },
{ value: '30min', label: '30 minutes' },
{ value: '1h', label: '1 hour' },
{ value: '2h', label: '2 hours' },
{ value: '3h', label: '3 hours' },
{ value: '4h', label: '4 hours' },
{ value: '5h', label: '5 hours' },
{ value: '6h', label: '6 hours' },
{ value: '8h', label: '8 hours' },
{ value: '12h', label: '12 hours' },
{ value: '1d', label: '1 day' },
{ value: '3d', label: '3 days' },
{ value: '5d', label: '5 days' },
{ value: '7d', label: '7 days' },
{ value: '1w', label: '1 week' },
{ value: '1.5w', label: '1.5 weeks' },
{ value: '2w', label: '2 weeks' },
{ value: '3w', label: '3 weeks' },
{ value: '30d', label: '1 month (30 days)' },
{ value: '45.625d', label: '1.5 months (~45 days)' },
{ value: '60d', label: '2 months (60 days)' },
{ value: '90d', label: '3 months (90 days)' },
{ value: '120d', label: '4 months (120 days)' },
{ value: '0.5 year', label: '6 months (0.5 year)' },
{ value: '1y', label: '1 year' },
{
value: '_',
label: 'Need more freedom? Set an exact date and time through the API.',
disabled: true,
},
]}
data={(() => {
// Build the full option list, then clamp by config.files.maxExpiration if provided.
const opts = [
{ value: 'default', label: `Default (${config.files.defaultExpiration ?? 'never'})` },
{ value: 'never', label: 'Never' },
{ value: '5min', label: '5 minutes' },
{ value: '10min', label: '10 minutes' },
{ value: '15min', label: '15 minutes' },
{ value: '30min', label: '30 minutes' },
{ value: '1h', label: '1 hour' },
{ value: '2h', label: '2 hours' },
{ value: '3h', label: '3 hours' },
{ value: '4h', label: '4 hours' },
{ value: '5h', label: '5 hours' },
{ value: '6h', label: '6 hours' },
{ value: '8h', label: '8 hours' },
{ value: '12h', label: '12 hours' },
{ value: '1d', label: '1 day' },
{ value: '3d', label: '3 days' },
{ value: '5d', label: '5 days' },
{ value: '7d', label: '7 days' },
{ value: '1w', label: '1 week' },
{ value: '1.5w', label: '1.5 weeks' },
{ value: '2w', label: '2 weeks' },
{ value: '3w', label: '3 weeks' },
{ value: '30d', label: '1 month (30 days)' },
{ value: '45.625d', label: '1.5 months (~45 days)' },
{ value: '60d', label: '2 months (60 days)' },
{ value: '90d', label: '3 months (90 days)' },
{ value: '120d', label: '4 months (120 days)' },
{ value: '0.5 year', label: '6 months (0.5 year)' },
{ value: '1y', label: '1 year' },
{
value: '_',
label: 'Need more freedom? Set an exact date and time through the API.',
disabled: true,
},
];
try {
const maxExp = settingsData?.files?.maxExpiration ?? null;
if (!maxExp) return opts;
const maxMs = ms(String(maxExp) as any);
if (!maxMs || isNaN(Number(maxMs))) return opts;
// Keep 'default' and 'never' always visible; clamp other duration options.
return opts.filter((o) => {
if (o.value === 'default' || o.value === 'never' || o.value === '_') return true;
const val = String(o.value);
const parsed = (ms as unknown as (v: string) => number)(val);
// Some labels like '45.625d' or '0.5 year' may be parseable; if not parseable, keep them to avoid excessive hiding.
if (!parsed || isNaN(Number(parsed))) return true;
return parsed <= Number(maxMs);
});
} catch {
return opts;
}
})()}
label={
<>
Deletes at{' '}
@@ -154,6 +186,11 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
{'.'}
</>
)}
{settingsData?.files?.maxExpiration ? (
<div style={{ marginTop: 6, color: 'var(--mantine-color-dimmed)' }}>
Note: maximum allowed expiration is <b>{settingsData.files.maxExpiration}</b>.
</div>
) : null}
</>
}
leftSection={<IconAlarmFilled size='1rem' />}
@@ -191,7 +228,44 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
description='The file name format to use when upload this file, the "File name" field will override this value.'
leftSection={<IconWriting size='1rem' />}
value={options.format}
onChange={(value) => setOption('format', value || ('default' as any))}
onChange={(value) => setOption('format', (value as any) || 'default')}
comboboxProps={{
withinPortal: true,
portalProps: {
style: {
zIndex: 100000000,
},
},
}}
/>
<Select
data={[
{ value: 'default', label: `Default (.${config.files.defaultCompressionFormat ?? 'jpg'})` },
{ value: 'jpg', label: '.jpg' },
{ value: 'png', label: '.png' },
{ value: 'webp', label: '.webp' },
{ value: 'jxl', label: '.jxl' },
]}
label={
<>
Compression Format{' '}
{options.imageCompressionFormat !== 'default' ? (
<Badge variant='outline' size='xs'>
saved
</Badge>
) : null}
</>
}
description={
<>
The image compression format to use <b>only when a compression percent is specified</b>. Leave
at &quot;default&quot; to use the server default compression format.
</>
}
leftSection={<IconFileInfo size='1rem' />}
value={options.imageCompressionFormat || 'default'}
onChange={(value) => setOption('imageCompressionFormat', (value as any) || 'default')}
comboboxProps={{
withinPortal: true,
portalProps: {
@@ -213,7 +287,7 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
) : null}
</>
}
description='The compression level to use on images (only). Leave blank to disable compression.'
description='The compression level to use on images (only). The above format will be used to compress images. Leave blank to disable compression.'
leftSection={<IconPercentage size='1rem' />}
max={100}
min={0}

View File

@@ -8,6 +8,40 @@ import { notifications } from '@mantine/notifications';
import { IconClipboardCopy, IconExternalLink, IconFileUpload, IconFileXFilled } from '@tabler/icons-react';
import { Link } from 'react-router-dom';
export function handleResponse<R = Response['/api/upload']>(
xml: XMLHttpRequest,
): { data: R | null; error: ErrorBody | null } {
if (xml.status < 200 || xml.status >= 300) {
return {
data: null,
error: {
statusCode: xml.status,
error: `Request failed with status code ${xml.status}: ${xml.responseText}`,
},
};
}
try {
const res = JSON.parse(xml.responseText) as R | ErrorBody;
if ((res as ErrorBody).statusCode) {
return { data: null, error: res as ErrorBody };
}
return { data: res as R, error: null };
} catch (e) {
console.error('Failed to parse server response:', e, xml.responseText);
return {
data: null,
error: {
statusCode: 500,
error: 'Failed to parse server response. See browser console for more details.',
},
};
}
}
export function filesModal(
files: Response['/api/upload']['files'],
{
@@ -150,21 +184,21 @@ export function uploadFiles(
req.addEventListener(
'load',
() => {
const res: Response['/api/upload'] = JSON.parse(req.responseText);
const { data: res, error } = handleResponse<Response['/api/upload']>(req);
setLoading(false);
setProgress({ percent: 0, remaining: 0, speed: 0 });
if ((res as ErrorBody).statusCode) {
if (error || !res) {
notifications.update({
id: 'upload',
title: 'Error uploading files',
message: (res as ErrorBody).error,
message: error?.error ?? 'An unknown error occurred',
color: 'red',
icon: <IconFileXFilled size='1rem' />,
autoClose: true,
loading: false,
});
return;
}
@@ -177,8 +211,9 @@ export function uploadFiles(
autoClose: true,
loading: false,
});
setFiles([]);
filesModal(res.files, { clipboard, clearEphemeral });
filesModal(res!.files, { clipboard, clearEphemeral });
},
false,
);
@@ -189,6 +224,8 @@ export function uploadFiles(
options.format !== 'default' && req.setRequestHeader('x-zipline-format', options.format);
options.imageCompressionPercent &&
req.setRequestHeader('x-zipline-image-compression-percent', options.imageCompressionPercent.toString());
options.imageCompressionFormat !== 'default' &&
req.setRequestHeader('x-zipline-image-compression-type', options.imageCompressionFormat);
options.maxViews && req.setRequestHeader('x-zipline-max-views', options.maxViews.toString());
options.addOriginalName && req.setRequestHeader('x-zipline-original-name', 'true');
options.overrides_returnDomain && req.setRequestHeader('x-zipline-domain', options.overrides_returnDomain);

View File

@@ -1,7 +1,6 @@
import { useConfig } from '@/components/ConfigProvider';
import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import { ErrorBody } from '@/lib/response';
import { UploadOptionsStore } from '@/lib/store/uploadOptions';
import { ActionIcon, Anchor, Group, Stack, Table, Text, Tooltip } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
@@ -9,6 +8,7 @@ import { modals } from '@mantine/modals';
import { hideNotification, notifications } from '@mantine/notifications';
import { IconClipboardCopy, IconExternalLink, IconFileUpload, IconFileXFilled } from '@tabler/icons-react';
import { Link } from 'react-router-dom';
import { handleResponse } from './uploadFiles';
export function filesModal(
files: Response['/api/upload']['files'],
@@ -162,13 +162,13 @@ export async function uploadPartialFiles(
req.addEventListener(
'load',
() => {
const res: Response['/api/upload/partial'] = JSON.parse(req.responseText);
const { data: res, error } = handleResponse<Response['/api/upload/partial']>(req);
if ((res as ErrorBody).error) {
if (error || !res) {
notifications.update({
id: 'upload-partial',
title: 'Error uploading files',
message: (res as ErrorBody).error,
message: error?.error ?? 'An unknown error occurred',
color: 'red',
icon: <IconFileXFilled size='1rem' />,
autoClose: false,
@@ -250,6 +250,8 @@ export async function uploadPartialFiles(
'x-zipline-image-compression-percent',
options.imageCompressionPercent.toString(),
);
options.imageCompressionFormat !== 'default' &&
req.setRequestHeader('x-zipline-image-compression-type', options.imageCompressionFormat);
options.maxViews && req.setRequestHeader('x-zipline-max-views', options.maxViews.toString());
options.addOriginalName && req.setRequestHeader('x-zipline-original-name', 'true');
options.overrides_returnDomain &&

View File

@@ -18,10 +18,10 @@ export default function EditUrlModal({
if (!url) return null;
const [maxViews, setMaxViews] = useState<number | null>(url?.maxViews ?? null);
const [password, setPassword] = useState<string | null>('');
const [vanity, setVanity] = useState<string | null>(url?.vanity ?? null);
const [destination, setDestination] = useState<string | null>(url?.destination ?? null);
const [enabled, setEnabled] = useState<boolean>(url?.enabled ?? true);
const [password, setPassword] = useState<string | null>('');
const handleRemovePassword = async () => {
if (!url.password) return;
@@ -53,15 +53,21 @@ export default function EditUrlModal({
const handleSave = async () => {
const data: {
maxViews?: number;
maxViews?: number | null;
password?: string;
vanity?: string;
destination?: string;
enabled?: boolean;
} = {};
if (maxViews !== null) data['maxViews'] = maxViews;
if (password !== null) data['password'] = password?.trim();
console.log(password);
if (maxViews === null) data['maxViews'] = null;
else data['maxViews'] = maxViews;
// dont include password if empty or null
if (password !== null && password.trim() !== '') data['password'] = password?.trim();
if (vanity !== null && vanity !== url.vanity) data['vanity'] = vanity?.trim();
if (destination !== null && destination !== url.destination) data['destination'] = destination?.trim();
if (enabled !== url.enabled) data['enabled'] = enabled;

View File

@@ -3,7 +3,7 @@ import { Response } from '@/lib/api/response';
import { Url } from '@/lib/db/models/url';
import { ActionIcon, Anchor, Box, Checkbox, Group, TextInput, Tooltip } from '@mantine/core';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useEffect, useReducer, useState } from 'react';
import { useEffect, useMemo, useReducer, useState } from 'react';
import useSWR from 'swr';
import { copyUrl, deleteUrl } from '../actions';
import { IconCopy, IconPencil, IconTrashFilled } from '@tabler/icons-react';
@@ -112,27 +112,23 @@ export default function UrlTableView() {
columnAccessor: 'createdAt',
direction: 'desc',
});
const [sorted, setSorted] = useState<Url[]>(data ?? []);
const [selectedUrl, setSelectedUrl] = useState<Url | null>(null);
useEffect(() => {
if (data) {
const sorted = data.sort((a, b) => {
const cl = sortStatus.columnAccessor as keyof Url;
const sorted = useMemo<Url[]>(() => {
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 Url;
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]);
useEffect(() => {
for (const field of ['code', 'vanity', 'destination'] as const) {

View File

@@ -69,6 +69,9 @@ export default function EditUserModal({
if (typeof value !== 'number' || value < 0) return 'Invalid value';
},
},
enhanceGetInputProps: ({ field }) => ({
name: field,
}),
});
const onSubmit = async (values: typeof form.values) => {
@@ -165,6 +168,7 @@ export default function EditUserModal({
<TextInput
label='Username'
placeholder='Enter a username...'
autoComplete='username'
{...form.getInputProps('username')}
/>
<PasswordInput

View File

@@ -2,10 +2,11 @@ import { type loader } from '@/client/pages/dashboard/admin/users/[id]/files';
import GridTableSwitcher from '@/components/GridTableSwitcher';
import { useViewStore } from '@/lib/store/view';
import { ActionIcon, Group, Title, Tooltip } from '@mantine/core';
import { IconArrowBackUp } from '@tabler/icons-react';
import { IconArrowBackUp, IconGridPatternFilled, IconTableOptions } from '@tabler/icons-react';
import { Link, useLoaderData } from 'react-router-dom';
import FileTable from '../files/views/FileTable';
import Files from '../files/views/Files';
import { useState } from 'react';
export default function ViewUserFiles() {
const data = useLoaderData<typeof loader>();
@@ -16,6 +17,9 @@ export default function ViewUserFiles() {
const view = useViewStore((state) => state.files);
const [tableEditOpen, setTableEditOpen] = useState(false);
const [idSearchOpen, setIdSearchOpen] = useState(false);
return (
<>
<Group>
@@ -26,10 +30,41 @@ export default function ViewUserFiles() {
</ActionIcon>
</Tooltip>
<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>
{view === 'grid' ? <Files id={user.id} /> : <FileTable id={user.id} />}
{view === 'grid' ? (
<Files id={user.id} />
) : (
<FileTable
id={user.id}
tableEdit={{
open: tableEditOpen,
setOpen: setTableEditOpen,
}}
idSearch={{
open: idSearchOpen,
setOpen: setIdSearchOpen,
}}
/>
)}
</>
);
}

View File

@@ -48,6 +48,9 @@ export default function DashboardUsers() {
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) => {
@@ -101,6 +104,7 @@ export default function DashboardUsers() {
<TextInput
label='Username'
placeholder='Enter a username...'
autoComplete='username'
{...form.getInputProps('username')}
/>
<PasswordInput

View File

@@ -1,46 +1,43 @@
import RelativeDate from '@/components/RelativeDate';
import { Response } from '@/lib/api/response';
import { User } from '@/lib/db/models/user';
import { canInteract, roleName } from '@/lib/role';
import { useUserStore } from '@/lib/store/user';
import { ActionIcon, Avatar, Box, Group, Tooltip } from '@mantine/core';
import { IconEdit, IconFiles, IconTrashFilled } from '@tabler/icons-react';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import EditUserModal from '../EditUserModal';
import RelativeDate from '@/components/RelativeDate';
import { canInteract, roleName } from '@/lib/role';
import { useUserStore } from '@/lib/store/user';
import { deleteUser } from '../actions';
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
import { deleteUser } from '../actions';
import EditUserModal from '../EditUserModal';
export default function UserTableView() {
const currentUser = useUserStore((state) => state.user);
const { data, isLoading } = useSWR<Extract<Response['/api/users'], User[]>>('/api/users?noincl=true');
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
columnAccessor: 'createdAt',
direction: 'desc',
});
const [sorted, setSorted] = useState<User[]>(data ?? []);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
useEffect(() => {
if (data) {
const sorted = data.sort((a, b) => {
const cl = sortStatus.columnAccessor as keyof User;
const sorted = useMemo<User[]>(() => {
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 User;
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 (
<>

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