Compare commits

..

173 Commits

Author SHA1 Message Date
diced 0b1db04159 fix: add errors to spec 2026-03-03 16:29:24 -08:00
diced 4735b102c3 fix: settings errors 2026-03-03 15:22:23 -08:00
diced 5d48735dfb fix: lint 2026-03-02 22:45:31 -08:00
diced ea9599a67a fix: more 2026-03-02 22:43:47 -08:00
diced 9bd22bd574 fix: responses + add descriptions 2026-03-02 22:43:40 -08:00
diced 6fef46246e refactor: generalized error codes 2026-03-02 19:57:23 -08:00
diced 3f159b3509 fix: finish up api refactor 2026-03-02 14:29:41 -08:00
diced eb3a58e790 feat: descriptions for api routes 2026-03-02 00:25:37 -08:00
diced 454b40501a refactor: models to zod 2026-03-01 22:41:36 -08:00
diced 4c6679b568 feat: add response schemas (WIP, hella unstable!!) 2026-03-01 14:57:16 -08:00
diced 3c757374e1 feat: revamp option selection for files page 2026-02-26 16:53:31 -08:00
diced c0e1aa9ac6 feat: revamp folders page 2026-02-26 16:11:49 -08:00
diced 40fd0b19eb feat: add multiple files for text uploads 2026-02-24 02:14:03 -08:00
diced 41240b7aff refactor: upload/partial logic + more sanitzation 2026-02-23 22:04:50 -08:00
diced 01f177fbc3 fix: permissions on docker scripts 2026-02-23 00:43:41 -08:00
diced ab1d394a46 fix: permissions 2026-02-23 00:42:01 -08:00
diced d08f1ba5da fix: #1002 2026-02-23 00:20:36 -08:00
diced 641a7c9b7b fix: maybe fix oauth issues #1001 2026-02-23 00:18:26 -08:00
diced a467ffe861 feat: new notifs position 2026-02-23 00:18:17 -08:00
dicedtomato 33ff667990 Merge commit from fork 2026-02-20 21:48:01 -08:00
diced e96015f5e0 fix: refactor + perf 2026-02-19 22:38:54 -08:00
diced d4d1cdc885 feat: revamped sessions 2026-02-15 21:18:02 -08:00
diced a7d831934d fix: add http but https warning 2026-02-12 16:30:56 -08:00
diced e9ef6a2d40 fix: #983 2026-02-12 16:05:28 -08:00
diced 7520efa835 fix: use exponential moving average for estimation (#996) 2026-02-12 15:55:22 -08:00
diced cff8454ac7 fix: no schema for settings api (from #990) 2026-02-12 14:53:38 -08:00
diced 847779601a fix: dev 2026-02-12 14:53:31 -08:00
Andrew Simonson 49c2088ea3 fix: max interval checks (#990)
* introduce max interval checks

* Update validate.ts

* Update validate.ts

* Update validate.ts

* Update validate.ts
2026-02-12 14:45:50 -08:00
diced 78600103af feat(v4.4.2): version 2026-02-10 20:49:14 -08:00
diced ce8b3ed36d fix: #985 2026-02-10 20:43:33 -08:00
diced 67641c2116 fix: proper length checks for login/register (#987) 2026-02-10 20:41:43 -08:00
diced acbbb7d40a feat: add docker scripts (ENTRYPOINT) + ziplinectl 2026-02-05 16:32:31 -08:00
Huang Cheng Ting 1f672cda3a fix: view route title & handle unicode characters in raw route (#980)
* fix: prioritize file original name in view route title

* fix: update Content-Disposition header to support unicode characters
fix: display issue with raw route when text contains unicode characters

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2026-02-05 16:05:49 -08:00
Huang Cheng Ting 2332d529e0 fix: prevent random character conflicts in uploads and urls (#978)
Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2026-02-05 16:03:08 -08:00
diced e910fe9da5 fix: folder issues 2026-02-05 15:59:35 -08:00
diced 4656599bb0 fix: use tmpdir() for initial 2026-02-05 15:59:21 -08:00
diced d6c33b6123 fix: clean up #961 2026-02-05 15:34:45 -08:00
Christoph Schlaepfer defcc7950d feat: nested folders (#961)
* Added nested folders feature

* Fixed Linting

* Fixed Linting

* Fixed Linting

* Fixed linting

* Fixed import

* Fixed dashboard view

* Fixed dashboard view

* Added DB Migration

* Fixed dropdown selection

* Added structured dropdowns to file dialog

* Fixes Nested Folder depth lookup & Breadcrumbs

* Fixes Nested Folder dropdowns

* Linting

* Fixes Export Filename

* Fixes export hierarchy

* Implemented Reviewed Feedback, improved code

* Removed more comments
2026-02-05 14:48:44 -08:00
diced 3d55ce0def fix: change buttons + add buttons 2026-02-04 14:29:25 -08:00
diced 8c9df5af5d feat: add domain selection to urls (#977) 2026-02-03 17:48:03 -08:00
diced 5c33ae134a fix: bunch of validation fixes 2026-02-02 21:13:58 -08:00
diced b628489330 fix: build errors 2026-01-31 18:36:34 -08:00
diced e9a6e31d4f fix: add passkey warning for old passkeys 2026-01-31 18:21:22 -08:00
diced ebe37cf7c1 fix: add warning when accessing over secure (login) 2026-01-31 18:11:29 -08:00
diced 529708110b fix: metrics validations 2026-01-31 15:01:49 -08:00
diced 9066dd37fb feat: remove built-in SSL 2026-01-31 14:16:51 -08:00
diced 45848925f4 fix: validation issues edit user 2026-01-28 16:29:54 -08:00
diced 2ba1da1671 fix: don't log db 2026-01-22 15:08:38 -08:00
diced 35c7d6b70c fix: #964 2026-01-22 15:03:23 -08:00
diced f45d1b770f fix: #966 2026-01-22 15:01:32 -08:00
diced 3650178ab3 fix: #968 2026-01-22 14:21:38 -08:00
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
356 changed files with 16455 additions and 7857 deletions
Executable → Regular
View File
Executable → Regular
View File
+48 -31
View File
@@ -1,53 +1,70 @@
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.
+3 -3
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?
Executable → Regular
+1 -10
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
Executable → Regular
View File
+99
View File
@@ -0,0 +1,99 @@
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 generator
env:
DATABASE_URL: postgres://zipline:zipline@localhost:5432/zipline
CORE_SECRET: ${{ steps.secret.outputs.secret }}
NODE_ENV: production
run: pnpm openapi
- 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
Executable → Regular
+3 -1
View File
@@ -48,4 +48,6 @@ yarn-error.log*
uploads*/
*.crt
*.key
src/prisma
src/prisma
.memory.log*
openapi.json
Executable → Regular
View File
Executable → Regular
+10 -4
View File
@@ -31,8 +31,7 @@ COPY tsconfig.json ./tsconfig.json
COPY mimes.json ./mimes.json
COPY code.json ./code.json
COPY vite-env.d.ts ./vite-env.d.ts
ENV NODE_ENV=production
COPY scripts ./scripts
RUN ZIPLINE_BUILD=true pnpm run build
@@ -45,14 +44,21 @@ 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/*
ENV NODE_ENV=production
ENV ZIPLINE_ROOT=/zipline
ARG ZIPLINE_GIT_SHA
ENV ZIPLINE_GIT_SHA=${ZIPLINE_GIT_SHA:-"unknown"}
CMD ["node", "--enable-source-maps", "build/server"]
# add scripts
COPY docker/entrypoint.sh /zipline/entrypoint
COPY docker/ziplinectl.sh /zipline/ziplinectl
RUN ln -s /zipline/ziplinectl /usr/local/bin/ziplinectl
ENTRYPOINT ["/zipline/entrypoint"]
Executable → Regular
View File
Executable → Regular
+14 -19
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).
@@ -261,10 +260,6 @@ DATASOURCE_LOCAL_DIRECTORY="/path/to/your/local/files"
# DATASOURCE_S3_BUCKET="your-bucket"
# DATASOURCE_S3_ENDPOINT="your-endpoint"
# ^ if using a custom endpoint other than aws s3
# optional but both are required if using ssl
# SSL_KEY="/path/to/your/ssl/key"
# SSL_CERT="/path/to/your/ssl/cert"
```
Install dependencies:
+1 -1
View File
@@ -4,7 +4,7 @@
| Version | Supported |
| ------- | ------------------ |
| 4.2.x | :white_check_mark: |
| 4.4.x | :white_check_mark: |
| < 3 | :x: |
| < 2 | :x: |
Executable → Regular
View File
Executable → Regular
+1 -1
View File
@@ -1,6 +1,6 @@
services:
postgres:
image: postgres:15
image: postgres:16
restart: unless-stopped
environment:
- POSTGRES_USER=postgres
Executable → Regular
+1 -1
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
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env sh
set -e
cd ${ZIPLINE_ROOT:-/zipline}
exec node --enable-source-maps build/server
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env sh
set -e
cd ${ZIPLINE_ROOT:-/zipline}
exec node --enable-source-maps build/ctl "$@"
+47 -54
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' },
},
},
);
Executable → Regular
+1
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"]],
Executable → Regular
+79 -76
View File
@@ -2,126 +2,129 @@
"name": "zipline",
"private": true,
"license": "MIT",
"version": "4.2.3",
"version": "4.4.2",
"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",
"openapi": "tsx scripts/openapi.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",
"docker:compose:dev:build": "docker-compose --file docker-compose.dev.yml build --build-arg ZIPLINE_GIT_SHA=$(git rev-parse HEAD)",
"docker:compose:dev:up": "docker-compose --file docker-compose.dev.yml up -d",
"docker:compose:dev:down": "docker-compose --file docker-compose.dev.yml down",
"docker:compose:dev:logs": "docker-compose --file docker-compose.dev.yml logs -f"
"docker:compose:dev:build": "docker compose --file docker-compose.dev.yml build --build-arg ZIPLINE_GIT_SHA=$(git rev-parse HEAD)",
"docker:compose:dev:up": "docker compose --file docker-compose.dev.yml up -d",
"docker:compose:dev:down": "docker compose --file docker-compose.dev.yml down",
"docker:compose:dev:logs": "docker compose --file docker-compose.dev.yml logs -f"
},
"dependencies": {
"@aws-sdk/client-s3": "3.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",
"detect-browser": "^5.3.0",
"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"
},
"packageManager": "pnpm@10.12.1"
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
}
Generated Executable → Regular
+3140 -2372
View File
File diff suppressed because it is too large Load Diff
Executable → Regular
View File
Executable → Regular
View File
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "filesDefaultCompressionFormat" TEXT DEFAULT 'jpg';
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "featuresThumbnailsFormat" TEXT NOT NULL DEFAULT 'jpg';
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "coreTrustProxy" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "filesMaxExpiration" TEXT;
@@ -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;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "tasksCleanThumbnailsInterval" TEXT NOT NULL DEFAULT '1d';
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "public"."Folder" ADD COLUMN "parentId" TEXT;
-- AddForeignKey
ALTER TABLE "public"."Folder" ADD CONSTRAINT "Folder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "public"."Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,23 @@
/*
Warnings:
- You are about to drop the column `sessions` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "public"."User" DROP COLUMN "sessions";
-- CreateTable
CREATE TABLE "public"."UserSession" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ua" TEXT NOT NULL,
"client" TEXT NOT NULL,
"device" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "UserSession_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "public"."UserSession" ADD CONSTRAINT "UserSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Executable → Regular
+43 -19
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 {
@@ -155,7 +163,7 @@ model User {
totpSecret String?
passkeys UserPasskey[]
sessions String[]
sessions UserSession[]
quota UserQuota?
@@ -183,6 +191,18 @@ model Export {
userId String
}
model UserSession {
id String @id
createdAt DateTime @default(now())
ua String
client String
device String
User User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
userId String
}
model UserQuota {
id String @id @default(cuid())
createdAt DateTime @default(now())
@@ -292,12 +312,16 @@ 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[]
parentId String?
parent Folder? @relation("FolderToFolder", fields: [parentId], references: [id], onDelete: SetNull, onUpdate: Cascade)
children Folder[] @relation("FolderToFolder")
User User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
userId String
}
+24
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',
),
);
+55
View File
@@ -0,0 +1,55 @@
type StepCommand = string | (() => void | Promise<void>);
export function step(name: string, command: StepCommand, 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}"...`);
if (typeof step.command === 'string') {
execSync(step.command, { stdio: 'inherit' });
} else {
await step.command();
}
} 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
View File
@@ -0,0 +1,3 @@
import { step } from '.';
export const lintStep = step('lint', 'eslint .');
+110
View File
@@ -0,0 +1,110 @@
import { readFile, writeFile } from 'fs/promises';
import path from 'path';
import { run, step } from '.';
import { API_ERRORS, ApiError, ApiErrorCode } from '../src/lib/api/errors';
const ALL_METHODS = ['delete', 'get', 'head', 'patch', 'post', 'put'];
const GEN_PATH = path.resolve(__dirname, '..', 'openapi.json');
const ALL_ERRORS = Object.keys(API_ERRORS)
.map((code) => new ApiError(Number(code) as ApiErrorCode).toJSON())
.sort((a, b) => a.code - b.code);
const ERROR_SCHEMA = {
type: 'object',
description: 'Generic error for API endpoints.',
properties: {
error: {
type: 'string',
description:
'Message for the error. This may differ from the standard message for the error code, but the error code should be used to figure out the type of error.',
},
code: {
type: 'integer',
format: 'int32',
description:
'Zipline API error code. Ranges: 1xxx validation, 2xxx session, 3xxx permission, 4xxx not-found, 5xxx constraint, 6xxx internal, 9xxx generic.',
enum: ALL_ERRORS.map((entry) => entry.code),
'x-enumDescriptions': ALL_ERRORS.map((entry) => entry.message),
},
statusCode: {
type: 'integer',
format: 'int32',
description: 'HTTP status code returned alongside this error payload.',
},
},
required: ['error', 'code', 'statusCode'],
additionalProperties: true,
};
const ERROR_EXAMPLES = ALL_ERRORS.reduce<Record<string, unknown>>((examples, entry) => {
examples[`E${entry.code}`] = {
summary: `${entry.error}`,
value: entry,
};
return examples;
}, {});
const generic4xxResponse = {
description: 'API error response (4xx)',
content: {
'application/json': {
schema: ERROR_SCHEMA,
examples: ERROR_EXAMPLES,
},
},
};
function addErrorResponse(responses: Record<string, any>): void {
const response = (responses['4xx'] ??= structuredClone(generic4xxResponse));
response.description ??= generic4xxResponse.description;
response.content ??= {};
const jsonContent = (response.content['application/json'] ??= {});
jsonContent.schema ??= structuredClone(ERROR_SCHEMA);
jsonContent.examples ??= structuredClone(generic4xxResponse.content['application/json'].examples);
}
function filterRoutes(paths = {}): Record<string, any> {
return Object.fromEntries(Object.entries(paths).filter(([route]) => route.startsWith('/api')));
}
async function fixSpec() {
const spec = JSON.parse(await readFile(GEN_PATH, 'utf8'));
spec.paths = filterRoutes(spec.paths);
for (const [, pathItem] of Object.entries(spec.paths ?? {})) {
if (!pathItem) continue;
for (const method of ALL_METHODS) {
const operation = (<any>pathItem)[method];
if (!operation) continue;
operation.responses ??= {};
addErrorResponse(operation.responses);
}
}
await writeFile(GEN_PATH, JSON.stringify(spec));
}
process.env.ZIPLINE_OUTPUT_OPENAPI = 'true';
run(
'openapi',
step('run-prod', 'pnpm start', () => process.env.NODE_ENV === 'production'),
step('run-dev', 'pnpm dev', () => process.env.NODE_ENV !== 'production'),
step('check', async () => {
try {
await readFile(GEN_PATH);
} catch (e) {
console.error('\nSomething went wrong...', e);
throw new Error('No OpenAPI spec found at ./openapi.json');
}
}),
step('fix', fixSpec),
);
+9
View File
@@ -0,0 +1,9 @@
import { run, step } from '.';
import { lintStep } from './lint';
run(
'validate',
lintStep,
step('format', 'prettier --write --ignore-path .gitignore .'),
);
+24 -2
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,8 +58,9 @@ export default function Root({
},
centered: true,
}}
modals={contextModals}
>
<Notifications zIndex={10000000} />
<Notifications position='top-center' zIndex={10000000} />
<Outlet />
</ModalsProvider>
</ThemeProvider>
+1 -1
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' }}
/>
);
}
+13 -2
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>
+1 -1
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' }}
/>
);
}
+2
View File
@@ -3,6 +3,8 @@
<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>
+3
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>
+123 -277
View File
@@ -1,61 +1,53 @@
import ExternalAuthButton from '@/components/pages/login/ExternalAuthButton';
import { Response } from '@/lib/api/response';
import LocalLogin from '@/components/pages/login/LocalLogin';
import PasskeyAuthButton from '@/components/pages/login/PasskeyAuthButton';
import SecureWarningModal from '@/components/pages/login/SecureWarningModal';
import TotpModal from '@/components/pages/login/TotpModal';
import { getWebClient } from '@/lib/api/detect';
import { ApiError } from '@/lib/api/errors';
import { fetchApi } from '@/lib/fetchApi';
import useLogin from '@/lib/hooks/useLogin';
import { authenticateWeb } from '@/lib/passkey';
import useObjectState from '@/lib/hooks/useObjectState';
import { useTitle } from '@/lib/hooks/useTitle';
import {
Button,
Anchor,
Box,
Center,
Divider,
Group,
Image,
LoadingOverlay,
Modal,
Paper,
PasswordInput,
PinInput,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications, showNotification } from '@mantine/notifications';
import { showNotification } from '@mantine/notifications';
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
import {
IconBrandDiscordFilled,
IconBrandGithubFilled,
IconBrandGoogleFilled,
IconCheck,
IconCircleKeyFilled,
IconKey,
IconShieldQuestion,
IconUserPlus,
IconX,
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { Link, redirect, useLocation, useNavigate } from 'react-router-dom';
import { 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');
const location = useLocation();
const query = new URLSearchParams(location.search);
const navigate = useNavigate();
const { user, mutate } = useLogin();
const navigate = useNavigate();
const isHttps = window.location.protocol === 'https:';
const webClient = JSON.stringify(getWebClient());
const {
data: config,
error: configError,
isLoading: configLoading,
} = useSWR<Response['/api/server/public']>('/api/server/public', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
});
const { data: config, error: configError, isLoading: configLoading } = useSWR('/api/server/public');
const showLocalLogin =
query.get('local') === 'true' ||
@@ -69,201 +61,122 @@ export default function Login() {
Object.values(config?.oauthEnabled ?? {}).filter((x) => x === true).length === 1 &&
query.get('local') !== 'true';
const [totpOpen, setTotpOpen] = useState(false);
const [pinDisabled, setPinDisabled] = useState(false);
const [pinError, setPinError] = useState('');
const [pin, setPin] = useState('');
const [passkeyErrored, setPasskeyErrored] = useState(false);
const [passkeyLoading, setPasskeyLoading] = useState(false);
const form = useForm({
initialValues: {
username: '',
password: '',
},
validate: {
username: (value) => (value.length > 1 ? null : 'Username is required'),
password: (value) => (value.length > 1 ? null : 'Password is required'),
},
});
const onSubmit = async (values: typeof form.values, code: string | undefined = undefined) => {
setPinDisabled(true);
setPinError('');
const { username, password } = values;
const { data, error } = await fetchApi<Response['/api/auth/login']>('/api/auth/login', 'POST', {
username,
password,
code,
});
if (error) {
if (error.error === 'Invalid username or password') {
form.setFieldError('username', 'Invalid username');
form.setFieldError('password', 'Invalid password');
} else if (error.error === 'Invalid code') setPinError(error.error!);
setPinDisabled(false);
} else {
if (data!.totp) {
setTotpOpen(true);
setPinDisabled(false);
return;
}
mutate(data as Response['/api/user']);
}
};
const handlePinChange = (value: string) => {
setPin(value);
if (value.length === 6) {
onSubmit(form.values, value);
}
};
const handlePasskeyLogin = async () => {
try {
setPasskeyLoading(true);
const res = await authenticateWeb();
const { data, error } = await fetchApi<Response['/api/auth/webauthn']>('/api/auth/webauthn', 'POST', {
auth: res.toJSON(),
});
if (error) {
setPasskeyErrored(true);
setPasskeyLoading(false);
notifications.show({
title: 'Error while authenticating with passkey',
message: error.error,
color: 'red',
});
} else {
mutate(data as Response['/api/user']);
}
} catch (e) {
console.log(e);
setPasskeyErrored(true);
setPasskeyLoading(false);
}
};
useEffect(() => {
if (user) {
navigate('/dashboard');
}
}, [user]);
useEffect(() => {
if (willRedirect && config) {
const provider = Object.keys(config.oauthEnabled).find(
(x) => config.oauthEnabled[x as keyof typeof config.oauthEnabled] === true,
);
if (provider) {
redirect(`/api/auth/oauth/${provider.toLowerCase()}`);
}
if (provider) window.location.href = `/api/auth/oauth/${provider.toLowerCase()}`;
}
}, [willRedirect, config]);
useEffect(() => {
if (passkeyErrored) {
setTimeout(() => {
setPasskeyErrored(false);
}, 3000);
const [totp, setTotp] = useObjectState({
open: false,
disabled: false,
error: '',
pin: '',
});
showNotification({
title: 'Error while authenticating with passkey',
message: 'Please try again',
color: 'red',
icon: <IconX size='1rem' />,
});
}
}, [passkeyErrored]);
const [secureModal, setSecureModal] = useState(false);
const form = useForm({
initialValues: { username: '', password: '' },
validate: {
username: (v) => (v.length >= 1 ? null : 'Username is required'),
password: (v) => (v.length >= 1 ? null : 'Password is required'),
},
});
useEffect(() => {
if (user) navigate('/dashboard');
if (config?.firstSetup) navigate('/auth/setup');
}, [config]);
}, [user, config, navigate]);
if (configLoading) return <LoadingOverlay visible />;
const handleLoginSubmit = async (values: any, code?: string) => {
setTotp({ disabled: true, error: '' });
if (configError)
return (
<GenericError
title='Error loading configuration'
message='Could not load server configuration...'
details={configError}
/>
const { data, error } = await fetchApi(
'/api/auth/login',
'POST',
{ ...values, code },
{ 'x-zipline-client': webClient },
);
if (!config) return <LoadingOverlay visible />;
if (error) {
if (ApiError.check(error, 1044)) {
form.setFieldError('username', 'Invalid username');
form.setFieldError('password', 'Invalid password');
} else {
setTotp('error', error.error || 'Login failed');
}
setTotp('disabled', false);
} else if (data?.totp) {
setTotp({ open: true, disabled: false });
} else {
showNotification({
message: 'Logging in...',
icon: <IconCheck size='1rem' />,
autoClose: 700,
});
mutate(data);
}
};
if (configLoading || !config) return <LoadingOverlay visible />;
if (configError) return <GenericError title='Error' message='Config load failed' details={configError} />;
const hasBg = !!config.website.loginBackground;
return (
<>
{willRedirect && !showLocalLogin && <LoadingOverlay visible />}
<Modal onClose={() => {}} title='Enter code' opened={totpOpen} withCloseButton={false}>
<Center>
<PinInput
data-autofocus
length={6}
oneTimeCode
type='number'
placeholder=''
onChange={handlePinChange}
autoFocus={true}
error={!!pinError}
disabled={pinDisabled}
size='xl'
/>
</Center>
{pinError && (
<Text ta='center' size='sm' c='red' mt={0}>
{pinError}
</Text>
)}
<TotpModal
state={totp}
onPinChange={(val) => setTotp('pin', val)}
onVerify={() => handleLoginSubmit(form.values, totp.pin)}
onCancel={() => {
setTotp('open', false);
form.reset();
}}
/>
<Group mt='sm' grow>
<Button
leftSection={<IconX size='1rem' />}
color='red'
variant='outline'
onClick={() => {
setTotpOpen(false);
form.reset();
}}
>
Cancel login attempt
</Button>
<Button
leftSection={<IconShieldQuestion size='1rem' />}
loading={pinDisabled}
type='submit'
onClick={() => onSubmit(form.values, pin)}
>
Verify
</Button>
</Group>
</Modal>
<SecureWarningModal
opened={secureModal}
onClose={() => setSecureModal(false)}
returnHttps={config.returnHttps}
/>
{isHttps && !config.returnHttps && (
<Box pos='absolute' top={10} left='50%' style={{ transform: 'translateX(-50%)' }}>
<Text size='sm' c='red' ta='center'>
You are accessing this instance through a <b>secure</b> context but the server is not configured
to use HTTPS. Click <Anchor onClick={() => setSecureModal(true)}> here</Anchor> to learn more.
</Text>
</Box>
)}
{!isHttps && config.returnHttps && (
<Box pos='absolute' top={10} left='50%' style={{ transform: 'translateX(-50%)' }}>
<Text size='sm' c='red' ta='center'>
You are accessing this instance through an <b>insecure</b> context but the server is configured to
use HTTPS. This may cause issues when logging in. Click{' '}
<Anchor onClick={() => setSecureModal(true)}> here</Anchor> to learn more.
</Text>
</Box>
)}
<Center h='100vh'>
{config.website.loginBackground && (
{hasBg && (
<Image
src={config.website.loginBackground}
alt={config.website.loginBackground + ' failed to load'}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
...(config.website.loginBackgroundBlur && { filter: 'blur(10px)' }),
}}
pos='absolute'
inset={0}
w='100%'
h='100%'
fit='cover'
style={{ filter: config.website.loginBackgroundBlur ? 'blur(10px)' : undefined }}
/>
)}
@@ -272,96 +185,29 @@ export default function Login() {
p='xl'
shadow='xl'
withBorder
pos='relative'
style={{
backgroundColor: config.website.loginBackground ? 'rgba(0, 0, 0, 0)' : undefined,
backdropFilter: config.website.loginBackgroundBlur ? 'blur(35px)' : undefined,
backgroundColor: hasBg ? 'transparent' : undefined,
backdropFilter: hasBg ? 'blur(35px)' : undefined,
}}
>
<div style={{ width: '100%', overflowWrap: 'break-word' }}>
<Title
order={1}
ta='center'
style={{
whiteSpace: 'normal',
fontSize: `clamp(20px, ${Math.max(
50 - (config.website.title?.length ?? 0) / 2,
20,
)}px, 50px)`,
}}
>
<b>{config.website.title ?? 'Zipline'}</b>
</Title>
</div>
<Title order={1} ta='center' mb='md'>
<b>{config.website.title ?? 'Zipline'}</b>
</Title>
{showLocalLogin && (
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<Stack my='sm'>
<TextInput
size='md'
placeholder='Enter your username...'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
},
}}
{...form.getInputProps('username', { withError: true })}
/>
<PasswordInput
size='md'
placeholder='Enter your password...'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
},
}}
{...form.getInputProps('password')}
/>
<Button
size='md'
fullWidth
type='submit'
loading={!config}
variant={config.website.loginBackground ? 'outline' : 'filled'}
>
Login
</Button>
</Stack>
</form>
)}
<Stack my='xs'>
{(config.features.oauthRegistration || config.features.userRegistration) && (
<Divider label='or' />
<Stack>
{showLocalLogin && (
<LocalLogin
form={form}
onSubmit={handleLoginSubmit}
loading={totp.disabled}
hasBackground={hasBg}
/>
)}
{config.mfa.passkeys && (
<Button
onClick={handlePasskeyLogin}
size='md'
fullWidth
variant='outline'
leftSection={<IconKey size='1rem' />}
color={passkeyErrored ? 'red' : undefined}
loading={passkeyLoading}
>
Login with passkey
</Button>
)}
<Divider label='or' />
{config.features.userRegistration && (
<Button
component={Link}
to='/auth/register'
size='md'
fullWidth
variant='outline'
leftSection={<IconUserPlus size='1rem' />}
>
Sign up
</Button>
)}
{config.mfa.passkeys && browserSupportsWebAuthn() && <PasskeyAuthButton onAuthSuccess={mutate} />}
<Group grow>
{config.oauthEnabled.discord && (
-35
View File
@@ -1,35 +0,0 @@
import { useTitle } from '@/lib/hooks/useTitle';
import { useUserStore } from '@/lib/store/user';
import { LoadingOverlay } from '@mantine/core';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { mutate } from 'swr';
export default function Logout() {
useTitle('Log out');
const setUser = useUserStore((state) => state.setUser);
const navigate = useNavigate();
useEffect(() => {
(async () => {
const userRes = await fetch('/api/user');
if (userRes.ok) {
const res = await fetch('/api/auth/logout');
if (res.ok) {
setUser(null);
mutate('/api/user', null);
navigate('/auth/login');
} else {
navigate('/dashboard');
}
} else {
navigate('/dashboard');
}
})();
}, []);
return <LoadingOverlay visible />;
}
+65 -30
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,14 @@ 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';
import { getWebClient } from '@/lib/api/detect';
import { ApiError } from '@/lib/api/errors';
export function Component() {
useTitle('Register');
@@ -30,7 +32,6 @@ export function Component() {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [invite, setInvite] = useState<any>(null);
const {
data: config,
@@ -44,6 +45,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: {
@@ -52,16 +66,19 @@ export function Component() {
tos: false,
},
validate: {
username: (value) => (value.length < 1 ? 'Username is required' : null),
password: (value) => (value.length < 1 ? 'Password is required' : null),
username: (value) => (value.length >= 1 ? null : 'Username is required'),
password: (value) => (value.length >= 1 ? null : 'Password is required'),
},
enhanceGetInputProps: ({ field }) => ({
name: field,
}),
});
useEffect(() => {
(async () => {
const res = await fetch('/api/user');
if (res.ok) {
redirect('/dashboard');
navigate('/dashboard');
} else {
setLoading(false);
}
@@ -69,21 +86,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]);
@@ -96,14 +101,21 @@ export function Component() {
return;
}
const { data, error } = await fetchApi('/api/auth/register', 'POST', {
username,
password,
code,
});
const { data, error } = await fetchApi(
'/api/auth/register',
'POST',
{
username,
password,
code,
},
{
'x-zipline-client': JSON.stringify(getWebClient()),
},
);
if (error) {
if (error.error === 'Username is taken') {
if (ApiError.check(error, 1039)) {
form.setFieldError('username', 'Username is taken');
} else {
notifications.show({
@@ -122,7 +134,7 @@ export function Component() {
});
mutate('/api/user');
redirect('/dashboard');
navigate('/dashboard');
}
};
@@ -138,6 +150,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 +211,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 +226,7 @@ export function Component() {
<TextInput
size='md'
placeholder='Enter your username...'
autoComplete='username'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
@@ -204,6 +238,7 @@ export function Component() {
<PasswordInput
size='md'
placeholder='Enter your password...'
autoComplete='new-password'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
+7 -2
View File
@@ -62,9 +62,12 @@ export function Component() {
password: '',
},
validate: {
username: (value) => (value.length < 1 ? 'Username is required' : null),
password: (value) => (value.length < 1 ? 'Password is required' : null),
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) => {
@@ -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>
@@ -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';
+18
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');
+125 -18
View File
@@ -1,8 +1,22 @@
import { type Response } from '@/lib/api/response';
import { ActionIcon, Container, Group, SimpleGrid, Skeleton, Title } from '@mantine/core';
import { IconUpload } from '@tabler/icons-react';
import { Folder } from '@/lib/db/models/folder';
import { FolderBreadcrumb } from '@/lib/folderHierarchy';
import {
ActionIcon,
Anchor,
Breadcrumbs,
Card,
Container,
Group,
SimpleGrid,
Skeleton,
Stack,
Text,
Title,
} from '@mantine/core';
import { IconFolder, IconUpload } from '@tabler/icons-react';
import { lazy, Suspense } from 'react';
import { Link, Params, useLoaderData } from 'react-router-dom';
import { Link, Params, useLoaderData, useNavigate } from 'react-router-dom';
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
@@ -16,12 +30,72 @@ export async function loader({ params }: { params: Params<string> }) {
};
}
function PublicFolderCard({ folder }: { folder: Partial<Folder> }) {
return (
<Link to={`/folder/${folder.id}`} style={{ textDecoration: 'none' }}>
<Card withBorder shadow='sm' radius='sm' style={{ cursor: 'pointer' }}>
<Card.Section withBorder inheritPadding py='xs'>
<Group gap='xs'>
<IconFolder size='1.2rem' />
<Text fw={500}>{folder.name}</Text>
</Group>
</Card.Section>
<Card.Section inheritPadding py='xs'>
<Stack gap={2}>
<Text size='xs' c='dimmed'>
{folder._count?.files ?? 0} files
</Text>
{(folder._count?.children ?? 0) > 0 && (
<Text size='xs' c='dimmed'>
{folder._count?.children} subfolders
</Text>
)}
</Stack>
</Card.Section>
</Card>
</Link>
);
}
export function Component() {
const { folder } = useLoaderData<typeof loader>();
const navigate = useNavigate();
const buildBreadcrumbs = () => {
const items: FolderBreadcrumb[] = [];
let current = folder.parent as Partial<Folder> | undefined;
while (current && current.public) {
items.unshift({ id: current.id!, name: current.name!, public: true });
current = current.parent as Partial<Folder> | undefined;
}
items.push({ id: folder.id!, name: folder.name!, public: true });
return items;
};
const breadcrumbs = buildBreadcrumbs();
const children = (folder.children ?? []) as Partial<Folder>[];
return (
<>
<Container my='lg'>
{breadcrumbs.length > 1 && (
<Breadcrumbs mb='md'>
{breadcrumbs.map((item, index) => (
<Anchor
key={item.id}
onClick={() => navigate(`/folder/${item.id}`)}
style={{ cursor: 'pointer' }}
fw={index === breadcrumbs.length - 1 ? 600 : 400}
>
{item.name}
</Anchor>
))}
</Breadcrumbs>
)}
<Group>
<Title order={1}>{folder.name}</Title>
@@ -34,21 +108,54 @@ export function Component() {
)}
</Group>
<SimpleGrid
my='sm'
cols={{
base: 1,
lg: 3,
md: 2,
}}
spacing='md'
>
{folder.files?.map((file: any) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} reduce />
</Suspense>
))}
</SimpleGrid>
{children.length > 0 && (
<>
<Title order={3} mt='md' mb='sm'>
Subfolders
</Title>
<SimpleGrid
cols={{
base: 1,
lg: 4,
md: 3,
sm: 2,
}}
spacing='md'
>
{children.map((child) => (
<PublicFolderCard key={child.id} folder={child} />
))}
</SimpleGrid>
</>
)}
{(folder.files?.length ?? 0) > 0 && (
<>
<Title order={3} mt='md' mb='sm'>
Files
</Title>
<SimpleGrid
cols={{
base: 1,
lg: 3,
md: 2,
}}
spacing='md'
>
{folder.files?.map((file: any) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} reduce />
</Suspense>
))}
</SimpleGrid>
</>
)}
{children.length === 0 && (folder.files?.length ?? 0) === 0 && (
<Text c='dimmed' mt='md'>
This folder is empty.
</Text>
)}
</Container>
</>
);
+6 -2
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'>
+13 -2
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.originalName ?? file.name ?? 'View File');
return password && !pw ? (
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
<form
@@ -91,7 +101,7 @@ export default function ViewFileId() {
<>
<Paper withBorder style={{ borderTop: 0, borderLeft: 0, borderRight: 0 }}>
<Group justify='space-between' py={5} px='xs'>
<Text c='dimmed'>{file.name}</Text>
<Text c='dimmed'>{file.originalName ?? file.name}</Text>
<Group>
<ActionIcon size='md' variant='outline' onClick={() => setDetailsOpen((o) => !o)}>
@@ -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}`,
@@ -156,7 +167,7 @@ export default function ViewFileId() {
<Group justify='space-between' mb='sm'>
<Group>
<Text size='lg' fw={700} display='flex'>
{file.name}{' '}
{file.originalName ?? file.name}{' '}
</Text>
{user?.view!.showTags && (
<Group gap={4}>
+15 -12
View File
@@ -1,24 +1,27 @@
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';
import Login from './pages/auth/login';
import Logout from './pages/auth/logout';
import Root from './Root';
export async function dashboardLoader() {
const res = await fetch('/api/server/settings/web');
if (!res.ok) {
return redirect('/auth/login');
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([
@@ -34,7 +37,6 @@ export const router = createBrowserRouter([
path: '/auth',
children: [
{ path: 'login', Component: Login },
{ path: 'logout', Component: Logout },
{ path: 'register', lazy: () => import('./pages/auth/register') },
{
path: 'setup',
@@ -55,7 +57,7 @@ export const router = createBrowserRouter([
{ path: 'metrics', lazy: () => import('./pages/dashboard/metrics') },
{ path: 'settings', lazy: () => import('./pages/dashboard/settings') },
{ path: 'files', lazy: () => import('./pages/dashboard/files') },
{ path: 'folders', lazy: () => import('./pages/dashboard/folders') },
{ path: 'folders/*', lazy: () => import('./pages/dashboard/folders') },
{ path: 'urls', lazy: () => import('./pages/dashboard/urls') },
{
path: 'upload',
@@ -78,6 +80,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: [
+3 -15
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
+56
View File
@@ -0,0 +1,56 @@
import { useMemo } from 'react';
import { useConfig } from './ConfigProvider';
import { Select, TextInput } from '@mantine/core';
import { IconGlobe } from '@tabler/icons-react';
export default function DomainSelect({
onChange,
...props
}: React.ComponentProps<typeof Select> & { onChange?: (value: string) => void }) {
const config = useConfig();
const domains = useMemo(() => {
const settingsDomains = config.domains;
if (!settingsDomains) return [];
if (!Array.isArray(settingsDomains)) return [];
return settingsDomains;
}, [config]);
const selectData = [
{ value: '', label: 'Default domain' },
...domains.map((domain) => ({
value: domain,
label: domain,
})),
];
if (domains.length === 0)
return (
<TextInput
description='Override the domain with this value. This will change the domain returned in your uploads. Leave blank to use the default domain.'
leftSection={<IconGlobe size='1rem' />}
placeholder='example.com'
{...(onChange
? {
onChange: (e) => onChange(e.currentTarget.value),
}
: {})}
{...(props as React.ComponentProps<typeof TextInput>)}
/>
);
return (
<Select
data={selectData}
description='Override the domain with this value. This will change the domain returned in your uploads. Leave blank to use the default domain.'
leftSection={<IconGlobe size='1rem' />}
{...(onChange
? {
onChange,
}
: {})}
{...props}
/>
);
}
View File
Executable → Regular
+10 -6
View File
@@ -41,6 +41,7 @@ import {
IconRefreshDot,
IconSettingsFilled,
IconShieldLockFilled,
IconStopwatch,
IconTags,
IconUpload,
IconUsersGroup,
@@ -50,6 +51,7 @@ import ConfigProvider from './ConfigProvider';
import VersionBadge from './VersionBadge';
import { Link, useLoaderData } from 'react-router-dom';
import { dashboardLoader } from '../client/routes';
import { useLogout } from '@/lib/hooks/useLogout';
type NavLinks = {
label: string;
@@ -126,6 +128,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' />,
@@ -151,6 +159,7 @@ export default function Layout() {
const clipboard = useClipboard();
const setUser = useUserStore((s) => s.setUser);
const location = useLocation();
const logout = useLogout();
const loaderData = useLoaderData<typeof dashboardLoader>();
const config = loaderData.config;
@@ -297,12 +306,7 @@ export default function Layout() {
)}
<Menu.Divider />
<Menu.Item
color='red'
leftSection={<IconLogout size='1rem' />}
component={Link}
to='/auth/logout'
>
<Menu.Item color='red' leftSection={<IconLogout size='1rem' />} onClick={logout}>
Logout
</Menu.Item>
</Menu.Dropdown>
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
+16 -12
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>
</>
);
}
+61 -33
View File
@@ -1,9 +1,10 @@
import { File } from '@/lib/db/models/file';
import { fetchApi } from '@/lib/fetchApi';
import useObjectState from '@/lib/hooks/useObjectState';
import { Button, Divider, Modal, NumberInput, PasswordInput, Stack, TextInput } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconEye, IconKey, IconPencil, IconPencilOff, IconTrashFilled } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { mutateFiles } from '../actions';
export default function EditFileDetailsModal({
@@ -15,13 +16,41 @@ export default function EditFileDetailsModal({
file: File | null;
onClose: () => void;
}) {
if (!file) return null;
const [formData, setFormData] = useObjectState<{
name: string;
maxViews: number | null;
password: string | null;
originalName: string | null;
type: string | null;
}>({
name: file?.name ?? '',
maxViews: file?.maxViews ?? null,
password: file?.password ? '' : null,
originalName: file?.originalName ?? null,
type: file?.type ?? null,
});
const [name, setName] = useState<string>(file.name ?? '');
const [maxViews, setMaxViews] = useState<number | null>(file?.maxViews ?? null);
const [password, setPassword] = useState<string | null>('');
const [originalName, setOriginalName] = useState<string | null>(file?.originalName ?? null);
const [type, setType] = useState<string | null>(file?.type ?? null);
useEffect(() => {
if (open) {
setFormData({
name: file?.name ?? '',
maxViews: file?.maxViews ?? null,
password: file?.password ? '' : null,
originalName: file?.originalName ?? null,
type: file?.type ?? null,
});
} else {
setFormData({
name: '',
maxViews: null,
password: null,
originalName: null,
type: null,
});
}
}, [open, file]);
if (!file) return null;
const handleRemovePassword = async () => {
if (!file.password) return;
@@ -58,12 +87,12 @@ export default function EditFileDetailsModal({
name?: string;
} = {};
if (maxViews !== null) data['maxViews'] = maxViews;
if (originalName !== null) data['originalName'] = originalName?.trim();
if (type !== null) data['type'] = type?.trim();
if (name !== file.name) data['name'] = name.trim();
if (formData.maxViews !== null) data['maxViews'] = formData.maxViews;
if (formData.originalName !== null) data['originalName'] = formData.originalName?.trim();
if (formData.type !== null) data['type'] = formData.type?.trim();
if (formData.name !== file.name) data['name'] = formData.name.trim();
const passwordTrimmed = password?.trim();
const passwordTrimmed = formData.password?.trim();
if (passwordTrimmed !== '') data['password'] = passwordTrimmed;
const { error } = await fetchApi(`/api/user/files/${file.id}`, 'PATCH', data);
@@ -85,29 +114,19 @@ export default function EditFileDetailsModal({
onClose();
setPassword(null);
setFormData('password', null);
mutateFiles();
}
};
useEffect(() => {
if (open) {
setName(file.name ?? '');
setMaxViews(file.maxViews ?? null);
setPassword(file.password ? '' : null);
setOriginalName(file.originalName ?? null);
setType(file.type ?? null);
}
}, [open, file]);
return (
<Modal zIndex={300} title={`Editing "${file.name}"`} onClose={onClose} opened={open}>
<Stack gap='xs' my='sm'>
<TextInput
label='Name'
description='Rename the file.'
value={name}
onChange={(event) => setName(event.currentTarget.value.trim())}
value={formData.name}
onChange={(event) => setFormData('name', event.currentTarget.value.trim())}
/>
<NumberInput
@@ -115,17 +134,20 @@ export default function EditFileDetailsModal({
placeholder='Unlimited'
description='The maximum number of views this file can have before it is deleted. Leave blank to allow as many views as you want.'
min={0}
value={maxViews || ''}
onChange={(value) => setMaxViews(value === '' ? null : Number(value))}
value={formData.maxViews || ''}
onChange={(value) => setFormData('maxViews', value === '' ? null : Number(value))}
leftSection={<IconEye size='1rem' />}
/>
<TextInput
label='Original Name'
description='Add an original name. When downloading this file, instead of using the generated file name (if chosen), it will download with this "original name" instead.'
value={originalName ?? ''}
value={formData.originalName ?? ''}
onChange={(event) =>
setOriginalName(event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim())
setFormData(
'originalName',
event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim(),
)
}
/>
@@ -137,9 +159,12 @@ export default function EditFileDetailsModal({
doing, this can mess with how Zipline renders specific file types.
</>
}
value={type ?? ''}
value={formData.type ?? ''}
onChange={(event) =>
setType(event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim())
setFormData(
'type',
event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim(),
)
}
c='red'
/>
@@ -159,10 +184,13 @@ export default function EditFileDetailsModal({
<PasswordInput
label='Password'
description='Set a password for this file. Leave blank to disable password protection.'
value={password ?? ''}
value={formData.password ?? ''}
autoComplete='off'
onChange={(event) =>
setPassword(event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim())
setFormData(
'password',
event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim(),
)
}
leftSection={<IconKey size='1rem' />}
/>
+55 -42
View File
@@ -1,10 +1,12 @@
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
import TagPill from '@/components/pages/files/tags/TagPill';
import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import { File } from '@/lib/db/models/file';
import { Folder } from '@/lib/db/models/folder';
import { Tag } from '@/lib/db/models/tag';
import { fetchApi } from '@/lib/fetchApi';
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
import { useFolders } from '@/lib/hooks/useFolders';
import { useSettingsStore } from '@/lib/store/settings';
import {
ActionIcon,
@@ -29,6 +31,7 @@ import { showNotification } from '@mantine/notifications';
import {
Icon,
IconBombFilled,
IconClipboardTypography,
IconCopy,
IconDeviceSdCard,
IconDownload,
@@ -46,8 +49,9 @@ import {
IconTrashFilled,
IconUpload,
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { useMemo, useState } from 'react';
import useSWR, { mutate } from 'swr';
import DashboardFileType from '../DashboardFileType';
import {
addToFolder,
@@ -88,36 +92,45 @@ 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);
const [editFileOpen, setEditFileOpen] = useState(false);
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
'/api/user/folders?noincl=true',
);
const { data: folders } = useFolders(user);
const folderOptions = useMemo(() => {
if (!folders) return [];
return buildFolderHierarchy(folders);
}, [folders]);
const folderCombobox = useCombobox();
const [search, setSearch] = useState('');
const handleAdd = async (value: string) => {
if (value === '$create') {
createFolderAndAdd(file!, search.trim());
await createFolderAndAdd(file!, search.trim());
} else {
addToFolder(file!, value);
await addToFolder(file!, value);
}
};
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 +180,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 +239,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 +259,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 +295,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 +318,8 @@ export default function FileModal({
</Button>
) : (
<Combobox
store={folderCombobox}
withinPortal={false}
store={folderCombobox}
onOptionSubmit={(value) => handleAdd(value)}
>
<Combobox.Target>
@@ -323,11 +331,17 @@ export default function FileModal({
folderCombobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
}}
onClick={() => folderCombobox.openDropdown()}
onFocus={() => folderCombobox.openDropdown()}
onClick={() => {
folderCombobox.openDropdown();
setSearch('');
}}
onFocus={() => {
folderCombobox.openDropdown();
setSearch('');
}}
onBlur={() => {
folderCombobox.closeDropdown();
setSearch(search || '');
setSearch('');
}}
placeholder='Add to folder...'
rightSectionPointerEvents='none'
@@ -335,24 +349,18 @@ export default function FileModal({
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{folders
?.filter((f: { name: string }) =>
f.name.toLowerCase().includes(search.toLowerCase().trim()),
)
.map((f: { name: string; id: string }) => (
<Combobox.Option value={f.id} key={f.id}>
{f.name}
</Combobox.Option>
))}
{!folders?.some((f: { name: string }) => f.name === search) &&
search.trim().length > 0 && (
<FolderComboboxOptions
folderOptions={folderOptions}
searchValue={search}
additionalOptions={
!folders?.some((f: { name: string }) => f.name === search) &&
search.trim().length > 0 ? (
<Combobox.Option value='$create'>
+ Create folder &quot;{search}&quot;
</Combobox.Option>
)}
</Combobox.Options>
) : null
}
/>
</Combobox.Dropdown>
</Combobox>
)}
@@ -398,6 +406,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
View File
+2 -2
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>
+47 -22
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)}
/>
Executable → Regular
+35 -34
View File
@@ -1,3 +1,4 @@
import { mutateFolder } from '@/components/pages/folders/actions';
import { Response } from '@/lib/api/response';
import type { File } from '@/lib/db/models/file';
import { Folder } from '@/lib/db/models/folder';
@@ -27,10 +28,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);
@@ -106,32 +111,33 @@ export async function favoriteFile(file: File) {
mutateFiles();
}
export function createFolderAndAdd(file: File, folderName: string | null) {
fetchApi<Extract<Response['/api/user/folders'], Folder>>('/api/user/folders', 'POST', {
name: folderName,
files: [file.id],
}).then(({ data, error }) => {
if (error) {
notifications.show({
title: 'Error while creating folder',
message: error.error,
color: 'red',
icon: <IconFolderOff size='1rem' />,
});
} else {
notifications.show({
title: 'Folder created',
message: `${data!.name} has been created with ${file.name}`,
color: 'green',
icon: <IconFolderPlus size='1rem' />,
});
}
});
export async function createFolderAndAdd(file: File, folderName: string | null) {
const { data, error } = await fetchApi<Extract<Response['/api/user/folders'], Folder>>(
'/api/user/folders',
'POST',
{
name: folderName,
files: [file.id],
},
);
if (error) {
notifications.show({
title: 'Error while creating folder',
message: error.error,
color: 'red',
icon: <IconFolderOff size='1rem' />,
});
} else {
notifications.show({
title: 'Folder created',
message: `${data!.name} has been created with ${file.name}`,
color: 'green',
icon: <IconFolderPlus size='1rem' />,
});
}
mutateFolders();
mutateFolder();
mutateFiles();
return undefined;
}
export async function removeFromFolder(file: File) {
@@ -160,7 +166,7 @@ export async function removeFromFolder(file: File) {
});
}
mutateFolders();
mutateFolder();
mutateFiles();
}
@@ -191,7 +197,7 @@ export async function addToFolder(file: File, folderId: string | null) {
});
}
mutateFolders();
mutateFolder();
mutateFiles();
}
@@ -223,7 +229,7 @@ export async function addMultipleToFolder(files: File[], folderId: string | null
});
}
mutateFolders();
mutateFolder();
mutateFiles();
}
@@ -231,8 +237,3 @@ export function mutateFiles() {
mutate('/api/user/recent');
mutate((key) => (key as Record<any, any>)?.key === '/api/user/files'); // paged files
}
export function mutateFolders() {
mutate('/api/user/folders');
mutate('/api/user/folders?noincl=true');
}
+3 -1
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,
@@ -0,0 +1,28 @@
import { FolderHierarchyItem } from '@/lib/folderHierarchy';
import { Combobox, Text } from '@mantine/core';
export default function FolderComboboxOptions({
folderOptions,
searchValue,
additionalOptions,
}: {
folderOptions: FolderHierarchyItem[];
searchValue: string;
additionalOptions?: React.ReactNode;
}) {
return (
<Combobox.Options>
{additionalOptions}
{folderOptions
.filter((f) => f.path.toLowerCase().includes(searchValue.toLowerCase().trim()))
.map((f) => (
<Combobox.Option value={f.id} key={f.id}>
<Text size='sm' style={{ paddingLeft: f.depth * 12 }}>
{f.depth > 0 ? '└ ' : ''}
{f.name}
</Text>
</Combobox.Option>
))}
</Combobox.Options>
);
}
+42 -8
View File
@@ -1,10 +1,20 @@
import { useConfig } from '@/components/ConfigProvider';
import Stat from '@/components/Stat';
import type { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import useLogin from '@/lib/hooks/useLogin';
import { Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text, Title } from '@mantine/core';
import { IconDeviceSdCard, IconEyeFilled, IconFiles, IconLink, IconStarFilled } from '@tabler/icons-react';
import { isAdministrator } from '@/lib/role';
import { Button, Group, Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text, Title } from '@mantine/core';
import {
IconDeviceSdCard,
IconEyeFilled,
IconFiles,
IconGraphFilled,
IconLink,
IconStarFilled,
} from '@tabler/icons-react';
import { lazy, Suspense } from 'react';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
@@ -13,6 +23,9 @@ export default function DashboardHome() {
const { user } = useLogin();
const { data: recent, isLoading: recentLoading } = useSWR<Response['/api/user/recent']>('/api/user/recent');
const { data: stats, isLoading: statsLoading } = useSWR<Response['/api/user/stats']>('/api/user/stats');
const config = useConfig();
return (
<>
<Title>
@@ -47,9 +60,18 @@ export default function DashboardHome() {
</Text>
) : null}
<Title order={2} mt='md' mb='xs'>
Recent files
</Title>
<Group mt='md' mb='xs' style={{ alignItems: 'center' }}>
<Title order={2}>Recent files</Title>
<Button
variant='outline'
size='compact-xs'
component={Link}
to='/dashboard/files'
leftSection={<IconFiles size='1rem' />}
>
View all files
</Button>
</Group>
{recentLoading ? (
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 'sm', md: 'md' }}>
@@ -71,9 +93,21 @@ export default function DashboardHome() {
</Text>
)}
<Title order={2} mt='md'>
Stats
</Title>
<Group mt='md' style={{ alignItems: 'center' }}>
<Title order={2}>Stats</Title>
{(!config.features?.metrics?.adminOnly || isAdministrator(user?.role)) && (
<Button
variant='outline'
size='compact-xs'
component={Link}
to='/dashboard/metrics'
leftSection={<IconGraphFilled size='1rem' />}
>
View instance metrics
</Button>
)}
</Group>
<Text size='sm' c='dimmed' mb='xs'>
These statistics are based on your uploads only.
</Text>
@@ -1,124 +0,0 @@
import { Response } from '@/lib/api/response';
import { IncompleteFile } from '@/lib/db/models/incompleteFile';
import { fetchApi } from '@/lib/fetchApi';
import { ActionIcon, Badge, Button, Card, Group, Modal, Paper, Stack, Text, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IncompleteFileStatus } from '@/prisma/client';
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
import { ReactNode, useState } from 'react';
import useSWR from 'swr';
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
PENDING: (
<Badge variant='light' color='gray'>
Pending
</Badge>
),
PROCESSING: (
<Badge variant='light' color='yellow'>
Processing
</Badge>
),
COMPLETE: (
<Badge variant='light' color='green'>
Complete
</Badge>
),
FAILED: (
<Badge variant='light' color='red'>
Failed
</Badge>
),
};
export default function PendingFilesButton() {
const [open, setOpen] = useState(false);
const { data: incompleteFiles, mutate } = useSWR<
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
>('/api/user/files/incomplete');
const handleDelete = async (incompleteFile: IncompleteFile) => {
const { error } = await fetchApi<Response['/api/user/files/incomplete']>(
'/api/user/files/incomplete',
'DELETE',
{
id: [incompleteFile.id],
},
);
if (error) {
showNotification({
title: 'Error',
message: `Failed to delete pending file: ${error.error}`,
color: 'red',
icon: <IconFileDots size='1rem' />,
});
} else {
showNotification({
message: 'Cleared Pending File!',
color: 'green',
icon: <IconTrashFilled size='1rem' />,
});
}
mutate();
};
return (
<>
<Modal opened={open} onClose={() => setOpen(false)} title='Pending Files'>
<Stack gap='xs'>
{incompleteFiles?.map((incompleteFile) => (
<Card key={incompleteFile.id} withBorder>
<Group justify='space-between'>
<Text fw='bolder'>{incompleteFile.metadata.file.filename}</Text>
{badgeMap[incompleteFile.status]}
</Group>
<Group justify='space-between'>
<Text size='xs' c='dimmed' fw='bold'>
{incompleteFile.metadata.file.type}
</Text>
<Text size='xs' c='dimmed'>
{incompleteFile.chunksComplete} / {incompleteFile.chunksTotal} processed
</Text>
</Group>
<Text size='xs' c='dimmed'>
{incompleteFile.id}
</Text>
<Group justify='space-between'>
<Button
fullWidth
size='compact-sm'
mt='xs'
color='red'
variant='light'
onClick={() => handleDelete(incompleteFile)}
leftSection={<IconTrashFilled size='1rem' />}
>
Clear
</Button>
</Group>
</Card>
))}
{incompleteFiles?.length === 0 && (
<Paper withBorder px='sm' py='xs'>
No pending files
</Paper>
)}
</Stack>
</Modal>
<Tooltip label='View pending files'>
<ActionIcon variant='outline' onClick={() => setOpen(true)}>
<IconFileDots size='1rem' />
</ActionIcon>
</Tooltip>
</>
);
}
@@ -0,0 +1,122 @@
import { Response } from '@/lib/api/response';
import { IncompleteFile } from '@/lib/db/models/incompleteFile';
import { fetchApi } from '@/lib/fetchApi';
import { UpdateFn } from '@/lib/hooks/useObjectState';
import { IncompleteFileStatus } from '@/prisma/client';
import { Badge, Button, Card, Group, Modal, Paper, Stack, Text } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
import { ReactNode } from 'react';
import useSWR from 'swr';
import { DashboardFilesModals } from '.';
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
PENDING: (
<Badge variant='light' color='gray'>
Pending
</Badge>
),
PROCESSING: (
<Badge variant='light' color='yellow'>
Processing
</Badge>
),
COMPLETE: (
<Badge variant='light' color='green'>
Complete
</Badge>
),
FAILED: (
<Badge variant='light' color='red'>
Failed
</Badge>
),
};
export default function PendingFilesModal({
modals,
setModals,
}: {
modals: DashboardFilesModals;
setModals: UpdateFn<DashboardFilesModals>;
}) {
const { data: incompleteFiles, mutate } = useSWR<
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
>('/api/user/files/incomplete');
const handleDelete = async (incompleteFile: IncompleteFile) => {
const { error } = await fetchApi<Response['/api/user/files/incomplete']>(
'/api/user/files/incomplete',
'DELETE',
{
id: [incompleteFile.id],
},
);
if (error) {
showNotification({
title: 'Error',
message: `Failed to delete pending file: ${error.error}`,
color: 'red',
icon: <IconFileDots size='1rem' />,
});
} else {
showNotification({
message: 'Cleared Pending File!',
color: 'green',
icon: <IconTrashFilled size='1rem' />,
});
}
mutate();
};
return (
<Modal opened={modals.pending} onClose={() => setModals('pending', false)}>
<Stack gap='xs'>
{incompleteFiles?.map((incompleteFile) => (
<Card key={incompleteFile.id} withBorder>
<Group justify='space-between'>
<Text fw='bolder'>{incompleteFile.metadata.file.filename}</Text>
{badgeMap[incompleteFile.status]}
</Group>
<Group justify='space-between'>
<Text size='xs' c='dimmed' fw='bold'>
{incompleteFile.metadata.file.type}
</Text>
<Text size='xs' c='dimmed'>
{incompleteFile.chunksComplete} / {incompleteFile.chunksTotal} processed
</Text>
</Group>
<Text size='xs' c='dimmed'>
{incompleteFile.id}
</Text>
<Group justify='space-between'>
<Button
fullWidth
size='compact-sm'
mt='xs'
color='red'
variant='light'
onClick={() => handleDelete(incompleteFile)}
leftSection={<IconTrashFilled size='1rem' />}
>
Clear
</Button>
</Group>
</Card>
))}
{incompleteFiles?.length === 0 && (
<Paper withBorder px='sm' py='xs'>
No pending files
</Paper>
)}
</Stack>
</Modal>
);
}
@@ -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>
);
}
+13 -10
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',
+70 -10
View File
@@ -1,19 +1,44 @@
import GridTableSwitcher from '@/components/GridTableSwitcher';
import useObjectState from '@/lib/hooks/useObjectState';
import { useViewStore } from '@/lib/store/view';
import { ActionIcon, Group, Title, Tooltip } from '@mantine/core';
import FavoriteFiles from './views/FavoriteFiles';
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 { ActionIcon, Group, Menu, Title, Tooltip } from '@mantine/core';
import {
IconDots,
IconFileDots,
IconFileUpload,
IconGridPatternFilled,
IconTableOptions,
IconTags,
} from '@tabler/icons-react';
import { Link } from 'react-router-dom';
import PendingFilesModal from './PendingFilesModal';
import TagsModal from './tags/TagsModal';
import FavoriteFiles from './views/FavoriteFiles';
import Files from './views/FilesGridView';
import FileTable from './views/FilesTableView';
export type DashboardFilesModals = {
table: boolean;
idSearch: boolean;
tags: boolean;
pending: boolean;
};
export default function DashboardFiles() {
const view = useViewStore((state) => state.files);
const [modals, setModals] = useObjectState<DashboardFilesModals>({
table: false,
idSearch: false,
tags: false,
pending: false,
});
return (
<>
<TagsModal modals={modals} setModals={setModals} />
<PendingFilesModal modals={modals} setModals={setModals} />
<Group>
<Title>Files</Title>
@@ -25,8 +50,43 @@ export default function DashboardFiles() {
</Link>
</Tooltip>
<TagsButton />
<PendingFilesButton />
<Menu>
<Menu.Target>
<Tooltip label='More actions'>
<ActionIcon variant='outline'>
<IconDots size='1rem' />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconTags size='1rem' />} onClick={() => setModals('tags', !modals.tags)}>
Manage Tags
</Menu.Item>
<Menu.Item
leftSection={<IconFileDots size='1rem' />}
onClick={() => setModals('pending', !modals.pending)}
>
View Pending Files
</Menu.Item>
{view === 'table' && (
<>
<Menu.Label>Table Options</Menu.Label>
<Menu.Item
leftSection={<IconGridPatternFilled size='1rem' />}
onClick={() => setModals('idSearch', !modals.idSearch)}
>
Search by ID
</Menu.Item>
<Menu.Item
leftSection={<IconTableOptions size='1rem' />}
onClick={() => setModals('table', !modals.table)}
>
Table Options
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
<GridTableSwitcher type='files' />
</Group>
@@ -38,7 +98,7 @@ export default function DashboardFiles() {
<Files />
</>
) : (
<FileTable />
<FileTable modals={modals} setModals={setModals} />
)}
</>
);
View File
View File
View File
@@ -2,17 +2,24 @@ import { mutateFiles } from '@/components/file/actions';
import { Response } from '@/lib/api/response';
import { Tag } from '@/lib/db/models/tag';
import { fetchApi } from '@/lib/fetchApi';
import { UpdateFn } from '@/lib/hooks/useObjectState';
import { ActionIcon, Group, Modal, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconPencil, IconPlus, IconTagOff, IconTags, IconTrashFilled } from '@tabler/icons-react';
import { IconPencil, IconPlus, IconTagOff, IconTrashFilled } from '@tabler/icons-react';
import { useState } from 'react';
import useSWR from 'swr';
import { DashboardFilesModals } from '..';
import CreateTagModal from './CreateTagModal';
import EditTagModal from './EditTagModal';
import TagPill from './TagPill';
export default function TagsButton() {
const [open, setOpen] = useState(false);
export default function TagsModals({
modals,
setModals,
}: {
modals: DashboardFilesModals;
setModals: UpdateFn<DashboardFilesModals>;
}) {
const [createModalOpen, setCreateModalOpen] = useState(false);
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
@@ -47,8 +54,8 @@ export default function TagsButton() {
<EditTagModal open={!!selectedTag} onClose={() => setSelectedTag(null)} tag={selectedTag} />
<Modal
opened={open}
onClose={() => setOpen(false)}
opened={modals.tags}
onClose={() => setModals('tags', false)}
title={
<Group>
<Title>Tags</Title>
@@ -94,12 +101,6 @@ export default function TagsButton() {
)}
</Stack>
</Modal>
<Tooltip label='View tags'>
<ActionIcon variant='outline' onClick={() => setOpen(true)}>
<IconTags size='1rem' />
</ActionIcon>
</Tooltip>
</>
);
}
+2
View File
@@ -19,6 +19,7 @@ type ApiPaginationOptions = {
| 'favorite';
order?: 'asc' | 'desc';
id?: string;
folderId?: string;
search?: {
field?: string;
query: string;
@@ -45,6 +46,7 @@ const fetcher = async (
if (options.search.field) searchParams.append('searchField', options.search.field);
searchParams.append('searchQuery', options.search.query);
}
if (options.folderId) searchParams.append('folder', options.folderId);
const res = await fetch(`/api/user/files${searchParams.toString() ? `?${searchParams.toString()}` : ''}`);
View File
@@ -1,3 +1,4 @@
import { useQueryState } from '@/lib/hooks/useQueryState';
import {
Button,
Center,
@@ -11,36 +12,30 @@ 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'));
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
export default function Files({ id }: { id?: string }) {
export default function Files({ id, folderId }: { id?: string; folderId?: string }) {
const [page, setPage] = useQueryState('page', 1);
const [perpage, setPerpage] = useState(15);
const [cachedPages, setCachedPages] = useState(1);
const { data, isLoading } = useApiPagination({
page,
perpage,
id,
folderId,
});
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 +54,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>
))
) : (
@@ -1,10 +1,14 @@
import RelativeDate from '@/components/RelativeDate';
import { addMultipleToFolder, copyFile, deleteFile, downloadFile } from '@/components/file/actions';
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import { type File } from '@/lib/db/models/file';
import { Folder } from '@/lib/db/models/folder';
import { Tag } from '@/lib/db/models/tag';
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
import { useFolders } from '@/lib/hooks/useFolders';
import { useQueryState } from '@/lib/hooks/useQueryState';
import { useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
import { useSettingsStore } from '@/lib/store/settings';
import {
ActionIcon,
@@ -32,18 +36,20 @@ import {
IconDownload,
IconExternalLink,
IconFile,
IconGridPatternFilled,
IconStar,
IconTrashFilled,
} from '@tabler/icons-react';
import { DataTable } from 'mantine-datatable';
import { lazy, useEffect, useReducer, useState } from 'react';
import { lazy, useEffect, useMemo, useReducer, useState } from 'react';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
import { UpdateFn } from '@/lib/hooks/useObjectState';
import { DashboardFilesModals } from '..';
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 +60,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 +87,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'
@@ -114,7 +113,7 @@ function TagsFilter({
const combobox = useCombobox();
const { data: tags } = useSWR<Extract<Response['/api/user/tags'], Tag[]>>('/api/user/tags');
const [value, setValue] = useState(searchQuery.tags.split(','));
const [value, setValue] = useState(() => searchQuery.tags.split(','));
const handleValueSelect = (val: string) => {
setValue((current) => (current.includes(val) ? current.filter((v) => v !== val) : [...current, val]));
};
@@ -179,13 +178,28 @@ function TagsFilter({
);
}
export default function FileTable({ id }: { id?: string }) {
export default function FileTable({
id,
folderId,
modals,
setModals,
}: {
id?: string;
folderId?: string;
modals?: Partial<DashboardFilesModals>;
setModals?: UpdateFn<DashboardFilesModals>;
}) {
const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
'/api/user/folders?noincl=true',
);
const fields = useFileTableSettingsStore((state) => state.fields);
const { data: folders } = useFolders();
const folderOptions = useMemo(() => {
if (!folders) return [];
return buildFolderHierarchy(folders);
}, [folders]);
const [page, setPage] = useQueryState('page', 1);
const [perpage, setPerpage] = useState(20);
@@ -202,36 +216,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();
@@ -256,6 +257,7 @@ export default function FileTable({ id }: { id?: string }) {
sort,
order,
id,
folderId,
...(searchQuery[searchField].trim() !== '' && {
search: {
field: searchField,
@@ -264,26 +266,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 +381,14 @@ 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>
{modals && setModals && modals.table && (
<TableEditModal opened={modals.table} onClose={() => setModals('table', false)} />
)}
<Box>
<Collapse in={selectedFiles.length > 0}>
<Paper withBorder p='sm' my='sm'>
<Text size='sm' c='dimmed' mb='xs'>
@@ -335,48 +415,54 @@ 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();
setFolderSearch('');
}}
onFocus={() => {
combobox.openDropdown();
setFolderSearch('');
}}
onBlur={() => {
combobox.closeDropdown();
setFolderSearch('');
}}
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>
<FolderComboboxOptions folderOptions={folderOptions} searchValue={folderSearch} />
</Combobox.Dropdown>
</Combobox>
)}
</Group>
<Button
@@ -393,99 +479,35 @@ export default function FileTable({ id }: { id?: string }) {
</Paper>
</Collapse>
<Collapse in={idSearchOpen}>
<Paper withBorder p='sm' my='sm'>
<TextInput
placeholder='Search by ID'
value={searchQuery.id}
onChange={(e) => {
setSearchField('id');
setSearchQuery({
field: 'id',
query: e.target.value,
});
}}
size='sm'
/>
</Paper>
</Collapse>
{modals && setModals && modals.idSearch && (
<Collapse in={modals.idSearch}>
<Paper withBorder p='sm' mt='sm'>
<TextInput
placeholder='Search by ID'
value={searchQuery.id}
onChange={(e) => {
setSearchField('id');
setSearchQuery({
field: 'id',
query: e.target.value,
});
}}
size='sm'
/>
</Paper>
</Collapse>
)}
{/* @ts-ignore */}
{/*@ts-ignore*/}
<DataTable
mt='xs'
borderRadius='sm'
withTableBorder
minHeight={200}
records={data?.page ?? []}
noRecordsText='No files'
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 +580,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
+76 -26
View File
@@ -5,7 +5,10 @@ import { useClipboard } from '@mantine/hooks';
import {
IconCopy,
IconDots,
IconFiles,
IconFileZip,
IconFolder,
IconFolderOpen,
IconFolderSymlink,
IconLock,
IconLockOpen,
IconPencil,
@@ -14,73 +17,115 @@ import {
IconTrashFilled,
} from '@tabler/icons-react';
import { useState } from 'react';
import ViewFilesModal from './ViewFilesModal';
import { copyFolderUrl, deleteFolder, editFolderUploads, editFolderVisibility } from './actions';
import EditFolderNameModal from './EditFolderNameModal';
import { copyFolderUrl, editFolderUploads, editFolderVisibility } from './actions';
import DeleteFolderModal from './modals/DeleteFolderModal';
import EditFolderNameModal from './modals/EditFolderNameModal';
import MoveFolderModal from './modals/MoveFolderModal';
import ViewFilesModal from './modals/ViewFilesModal';
import { withoutPropagation } from './views/FolderTableView';
export default function FolderCard({ folder }: { folder: Folder }) {
export default function FolderCard({
folder,
onNavigate,
}: {
folder: Folder;
onNavigate?: (folderId: string | null) => void;
}) {
const clipboard = useClipboard();
const [viewOpen, setViewOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [moveOpen, setMoveOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const childrenCount = folder._count?.children ?? 0;
const filesCount = folder._count?.files ?? folder.files?.length ?? 0;
return (
<>
<ViewFilesModal opened={viewOpen} onClose={() => setViewOpen(false)} folder={folder} />
<EditFolderNameModal folder={folder} opened={editOpen} onClose={() => setEditOpen(false)} />
<MoveFolderModal folder={folder} opened={moveOpen} onClose={() => setMoveOpen(false)} />
<DeleteFolderModal opened={deleteOpen} folder={folder} onClose={() => setDeleteOpen(false)} />
<Card withBorder shadow='sm' radius='sm'>
<Card.Section withBorder inheritPadding py='xs'>
<Card withBorder shadow='sm' radius='sm' style={{ cursor: onNavigate ? 'pointer' : 'default' }}>
<Card.Section withBorder inheritPadding py='xs' onClick={() => onNavigate?.(folder.id)}>
<Group justify='space-between'>
<Text fw={400}>
{folder.public ? (
<Anchor href={`/folder/${folder.id}`} target='_blank'>
{folder.name}
</Anchor>
) : (
folder.name
)}
</Text>
<Group gap='xs'>
<IconFolder size='1rem' />
<Text fw={400}>
{folder.public ? (
<Anchor href={`/folder/${folder.id}`} target='_blank' onClick={(e) => e.stopPropagation()}>
{folder.name}
</Anchor>
) : (
folder.name
)}
</Text>
</Group>
<Menu withinPortal position='bottom-end' shadow='sm'>
<Group gap={2}>
<Menu.Target>
<ActionIcon variant='transparent'>
<ActionIcon variant='transparent' onClick={(e) => e.stopPropagation()}>
<IconDots size='1rem' />
</ActionIcon>
</Menu.Target>
</Group>
<Menu.Dropdown>
<Menu.Item leftSection={<IconFiles size='1rem' />} onClick={() => setViewOpen(true)}>
View Files
{onNavigate && (
<Menu.Item
leftSection={<IconFolderOpen size='1rem' />}
onClick={() => onNavigate(folder.id)}
>
Open Folder
</Menu.Item>
)}
<Menu.Item
leftSection={<IconFolderSymlink size='1rem' />}
onClick={withoutPropagation(() => setMoveOpen(true))}
>
Move Folder
</Menu.Item>
<Menu.Item
leftSection={<IconFileZip size='1rem' />}
component='a'
href={`/api/user/folders/${folder.id}/export`}
target='_blank'
onClick={withoutPropagation(() => {})}
>
Export as ZIP
</Menu.Item>
<Menu.Item
leftSection={folder.public ? <IconLock size='1rem' /> : <IconLockOpen size='1rem' />}
onClick={() => editFolderVisibility(folder, !folder.public)}
onClick={withoutPropagation(() => editFolderVisibility(folder, !folder.public))}
>
{folder.public ? 'Make Private' : 'Make Public'}
</Menu.Item>
<Menu.Item
leftSection={folder.public ? <IconShareOff size='1rem' /> : <IconShare size='1rem' />}
onClick={() => editFolderUploads(folder, !folder.allowUploads)}
onClick={withoutPropagation(() => editFolderUploads(folder, !folder.allowUploads))}
>
{folder.allowUploads ? 'Disallow anonymous uploads' : 'Allow anonymous uploads'}
</Menu.Item>
<Menu.Item leftSection={<IconPencil size='1rem' />} onClick={() => setEditOpen(true)}>
<Menu.Item
leftSection={<IconPencil size='1rem' />}
onClick={withoutPropagation(() => setEditOpen(true))}
>
Edit Name
</Menu.Item>
<Menu.Item
leftSection={<IconCopy size='1rem' />}
disabled={!folder.public}
onClick={() => copyFolderUrl(folder, clipboard)}
onClick={withoutPropagation(() => copyFolderUrl(folder, clipboard))}
>
Copy URL
</Menu.Item>
<Menu.Item
leftSection={<IconTrashFilled size='1rem' />}
color='red'
onClick={() => deleteFolder(folder)}
onClick={withoutPropagation(() => setDeleteOpen(true))}
>
Delete
</Menu.Item>
@@ -89,7 +134,7 @@ export default function FolderCard({ folder }: { folder: Folder }) {
</Group>
</Card.Section>
<Card.Section inheritPadding py='xs'>
<Card.Section inheritPadding py='xs' onClick={() => onNavigate?.(folder.id)}>
<Stack gap={1}>
<Text size='xs' c='dimmed'>
<b>Created:</b> <RelativeDate date={folder.createdAt} />
@@ -101,8 +146,13 @@ export default function FolderCard({ folder }: { folder: Folder }) {
<b>Public:</b> {folder.public ? 'Yes' : 'No'}
</Text>
<Text size='xs' c='dimmed'>
<b>Files:</b> {folder.files!.length}
<b>Files:</b> {filesCount}
</Text>
{childrenCount > 0 && (
<Text size='xs' c='dimmed'>
<b>Subfolders:</b> {childrenCount}
</Text>
)}
</Stack>
</Card.Section>
</Card>
+5 -43
View File
@@ -3,27 +3,11 @@ import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { Anchor } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import { IconCheck, IconCopy, IconFolderOff } from '@tabler/icons-react';
import { Link } from 'react-router-dom';
import { mutate } from 'swr';
export async function deleteFolder(folder: Folder) {
modals.openConfirmModal({
centered: true,
title: `Delete ${folder.name}?`,
children: `Are you sure you want to delete ${folder.name}? This action cannot be undone.`,
labels: {
cancel: 'Cancel',
confirm: 'Delete',
},
confirmProps: { color: 'red' },
onConfirm: () => handleDeleteFolder(folder),
onCancel: modals.closeAll,
});
}
export function copyFolderUrl(folder: Folder, clipboard: ReturnType<typeof useClipboard>) {
clipboard.copy(`${window.location.protocol}//${window.location.host}/folder/${folder.id}`);
@@ -64,7 +48,7 @@ export async function editFolderVisibility(folder: Folder, isPublic: boolean) {
});
}
mutate('/api/user/folders');
mutateFolder(folder.id);
}
export async function editFolderUploads(folder: Folder, allowUploads: boolean) {
@@ -92,33 +76,11 @@ export async function editFolderUploads(folder: Folder, allowUploads: boolean) {
});
}
mutate('/api/user/folders');
mutateFolder(folder.id);
}
async function handleDeleteFolder(folder: Folder) {
const { data, error } = await fetchApi<Response['/api/user/folders/[id]']>(
`/api/user/folders/${folder.id}`,
'DELETE',
{
delete: 'folder',
},
);
export async function mutateFolder(folderId?: string) {
if (folderId) return mutate(`/api/user/folders/${folderId}`);
if (error) {
notifications.show({
title: 'Failed to delete folder',
message: error.error,
color: 'red',
icon: <IconFolderOff size='1rem' />,
});
} else {
notifications.show({
title: 'Folder deleted',
message: `${data?.name} has been deleted`,
color: 'green',
icon: <IconCheck size='1rem' />,
});
}
mutate('/api/user/folders');
return mutate((key) => typeof key === 'string' && key.startsWith('/api/user/folders'));
}
+178 -12
View File
@@ -2,20 +2,60 @@ import GridTableSwitcher from '@/components/GridTableSwitcher';
import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { FolderBreadcrumb } from '@/lib/folderHierarchy';
import { SEPARATOR, useTitle } from '@/lib/hooks/useTitle';
import { useViewStore } from '@/lib/store/view';
import { ActionIcon, Button, Group, Modal, Stack, Switch, TextInput, Title, Tooltip } from '@mantine/core';
import {
Alert,
Anchor,
Box,
Breadcrumbs,
Button,
Collapse,
CopyButton,
Divider,
Group,
Modal,
Paper,
Stack,
Switch,
Text,
TextInput,
Title,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { IconFolderPlus, IconPlus } from '@tabler/icons-react';
import { useState } from 'react';
import { mutate } from 'swr';
import { IconFolderPlus, IconHome, IconPlus, IconShare } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import FilesGridView from '../files/views/FilesGridView';
import FilesTableView from '../files/views/FilesTableView';
import { mutateFolder } from './actions';
import FolderGridView from './views/FolderGridView';
import FolderTableView from './views/FolderTableView';
export default function DashboardFolders() {
const view = useViewStore((state) => state.folders);
const location = useLocation();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [filesOpen, setFilesOpen] = useState(true);
const folderPath = useMemo(() => {
const pathname = location.pathname.replace('/dashboard/folders', '');
if (!pathname || pathname === '/') return [];
return pathname.split('/').filter(Boolean);
}, [location.pathname]);
const currentFolderId = folderPath.length > 0 ? folderPath[folderPath.length - 1] : null;
const {
data: currentFolder,
error: currentFolderError,
isLoading,
} = useSWR<Folder>(currentFolderId ? `/api/user/folders/${currentFolderId}` : null);
const form = useForm({
initialValues: {
@@ -34,6 +74,7 @@ export default function DashboardFolders() {
{
name: values.name,
isPublic: values.isPublic,
parentId: currentFolderId ?? undefined,
},
);
@@ -43,15 +84,71 @@ export default function DashboardFolders() {
color: 'red',
});
} else {
mutate('/api/user/folders');
mutateFolder();
setOpen(false);
form.reset();
}
};
const navigateToFolder = useCallback(
(folderId: string | null) => {
if (folderId === null) {
navigate('/dashboard/folders');
} else {
const newPath = [...folderPath, folderId];
navigate(`/dashboard/folders/${newPath.join('/')}`);
}
},
[navigate, folderPath],
);
const buildBreadcrumbs = () => {
const items: FolderBreadcrumb[] = [{ id: null, name: 'Root', path: '/dashboard/folders' }];
if (currentFolder) {
const path: Partial<Folder>[] = [];
let folder: Partial<Folder> | undefined | null = currentFolder;
while (folder) {
path.unshift(folder);
folder = folder.parent;
}
const folderIds: string[] = [];
for (const f of path) {
folderIds.push(f.id!);
items.push({
id: f.id!,
name: f.name!,
path: `/dashboard/folders/${folderIds.join('/')}`,
});
}
}
return items;
};
const breadcrumbs = buildBreadcrumbs();
useTitle(currentFolder ? `Folders ${SEPARATOR} ${currentFolder.name}` : 'Folders');
useEffect(() => {
if (!currentFolderId) return;
if (isLoading) return;
if (currentFolderError || !currentFolder) {
navigate('/dashboard/folders', { replace: true });
}
}, [currentFolderId, currentFolder, currentFolderError, isLoading]);
return (
<>
<Modal centered opened={open} onClose={() => setOpen(false)} title='Create a folder'>
<Modal
centered
opened={open}
onClose={() => setOpen(false)}
title={currentFolderId ? 'Create a subfolder' : 'Create a folder'}
>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='sm'>
<TextInput label='Name' placeholder='Enter a name...' {...form.getInputProps('name')} />
@@ -71,16 +168,85 @@ export default function DashboardFolders() {
<Group>
<Title>Folders</Title>
<Tooltip label='Create a new folder'>
<ActionIcon variant='outline' onClick={() => setOpen(true)}>
<IconPlus size='1rem' />
</ActionIcon>
</Tooltip>
<Button
variant='outline'
size='compact-sm'
leftSection={<IconPlus size='1rem' />}
onClick={() => setOpen(true)}
>
Create{currentFolderId ? ' Subfolder' : ' Folder'}
</Button>
<GridTableSwitcher type='folders' />
</Group>
{view === 'grid' ? <FolderGridView /> : <FolderTableView />}
{breadcrumbs.length > 1 && (
<Breadcrumbs my='sm'>
{breadcrumbs.map((item, index) => (
<Anchor
key={item.id ?? 'root'}
onClick={() => navigate(item.path!)}
style={{ cursor: 'pointer' }}
fw={index === breadcrumbs.length - 1 ? 600 : 400}
>
{index === 0 ? <IconHome size='1rem' /> : item.name}
</Anchor>
))}
</Breadcrumbs>
)}
{view === 'grid' ? (
<FolderGridView currentFolderId={currentFolderId} onNavigate={navigateToFolder} />
) : (
<FolderTableView currentFolderId={currentFolderId} onNavigate={navigateToFolder} />
)}
{currentFolderId && currentFolder && (
<Box>
<Divider mx='-xs' my='xs' />
{currentFolder?.allowUploads && (
<Alert
icon={<IconShare size='1rem' />}
variant='outline'
mb='sm'
styles={{ message: { marginTop: 0 } }}
>
This folder allows anonymous uploads. Share the link below to allow others to let others upload
files to this folder.
<br />
<Anchor href={`/folder/${currentFolder.id}/upload`} target='_blank'>
{`${window?.location?.origin ?? ''}/folder/${currentFolder.id}/upload`}
</Anchor>
<CopyButton value={`${window?.location?.origin ?? ''}/folder/${currentFolder.id}/upload`}>
{({ copied, copy }) => (
<Button mx='sm' size='compact-xs' color={copied ? 'teal' : 'blue'} onClick={copy}>
{copied ? 'Copied url' : 'Copy url'}
</Button>
)}
</CopyButton>
</Alert>
)}
<Text
mt='sm'
c='dimmed'
size='sm'
onClick={() => setFilesOpen((o) => !o)}
style={{ cursor: 'pointer', userSelect: 'none' }}
>
{filesOpen ? '▼' : '▶'} {currentFolder.name}&#39;s files{' '}
{currentFolder._count ? `(${currentFolder._count.files})` : ''}
</Text>
<Collapse in={filesOpen}>
{view === 'grid' ? (
<Paper withBorder p='sm'>
<FilesGridView folderId={currentFolderId} />
</Paper>
) : (
<FilesTableView folderId={currentFolderId} />
)}
</Collapse>
</Box>
)}
</>
);
}
@@ -0,0 +1,192 @@
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
import { useFolders } from '@/lib/hooks/useFolders';
import { Button, Combobox, InputBase, Modal, Radio, Stack, Text, useCombobox } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconTrashFilled } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { mutateFolder } from '../actions';
type ChildrenAction = 'root' | 'folder' | 'cascade';
export default function DeleteFolderModal({
folder,
opened,
onClose,
}: {
folder: Folder | null;
opened: boolean;
onClose: () => void;
}) {
const [loading, setLoading] = useState(false);
const [childrenAction, setChildrenAction] = useState<ChildrenAction>('root');
const [targetFolderId, setTargetFolderId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const combobox = useCombobox();
const { data: allFolders } = useFolders(undefined, opened);
const folderOptions = useMemo(() => {
if (!allFolders || !folder) return [];
// Exclude the folder being deleted
const excludeIds = new Set([folder.id]);
return buildFolderHierarchy(allFolders, excludeIds);
}, [allFolders, folder]);
if (!folder) return null;
const hasChildren = (folder._count?.children ?? 0) > 0;
const hasFiles = (folder._count?.files ?? 0) > 0;
const hasContent = hasChildren || hasFiles;
const getDisplayValue = () => {
const selected = folderOptions.find((f) => f.id === targetFolderId);
return selected?.path || '';
};
const handleDelete = async () => {
setLoading(true);
const body: any = {
delete: 'folder',
};
if (hasContent) {
body.childrenAction = childrenAction;
if (childrenAction === 'folder') {
if (!targetFolderId) {
notifications.show({
title: 'No folder selected',
message: 'Please select a folder to move contents to',
color: 'red',
});
setLoading(false);
return;
}
body.targetFolderId = targetFolderId;
}
}
const { error } = await fetchApi<Response['/api/user/folders/[id]']>(
`/api/user/folders/${folder.id}`,
'DELETE',
body,
);
setLoading(false);
if (error) {
notifications.show({
title: 'Failed to delete folder',
message: error.error,
color: 'red',
});
} else {
notifications.show({
title: 'Folder deleted',
message: `${folder.name} has been deleted`,
color: 'green',
});
mutateFolder();
onClose();
}
};
return (
<Modal centered opened={opened} onClose={onClose} title={`Delete "${folder.name}"?`}>
<Stack gap='sm'>
<Text size='sm' c='red' fw={500}>
This action cannot be undone.
</Text>
{hasContent && (
<>
<Text size='sm'>
This folder contains {hasFiles && `${folder._count?.files} file(s)`}
{hasChildren && hasFiles && ' and '}
{hasChildren && `${folder._count?.children} subfolder(s)`}. What would you like to do with them?
</Text>
<Radio.Group value={childrenAction} onChange={(v) => setChildrenAction(v as ChildrenAction)}>
<Stack gap='xs'>
<Radio value='root' label='Move contents to root folder' />
<Radio value='folder' label='Move contents to another folder' />
<Radio
value='cascade'
label={
<Text size='sm' c='red'>
Delete everything (cascade delete)
</Text>
}
/>
</Stack>
</Radio.Group>
{childrenAction === 'folder' && (
<Combobox
store={combobox}
withinPortal={true}
onOptionSubmit={(value) => {
setTargetFolderId(value);
setSearch(folderOptions.find((f) => f.id === value)?.path || '');
combobox.closeDropdown();
}}
>
<Combobox.Target>
<InputBase
label='Target Folder'
placeholder='Select a folder'
rightSection={<Combobox.Chevron />}
value={search || getDisplayValue()}
onChange={(event) => {
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
}}
onClick={() => {
combobox.openDropdown();
setSearch('');
}}
onFocus={() => {
combobox.openDropdown();
setSearch('');
}}
onBlur={() => {
combobox.closeDropdown();
setSearch('');
}}
rightSectionPointerEvents='none'
required
/>
</Combobox.Target>
<Combobox.Dropdown>
<FolderComboboxOptions folderOptions={folderOptions} searchValue={search} />
</Combobox.Dropdown>
</Combobox>
)}
{childrenAction === 'cascade' && (
<Text size='sm' c='red' fw={500}>
Warning: This will permanently delete all contents within this folder (subfolders will be
deleted, and files will be unlinked from their folders).
</Text>
)}
</>
)}
<Button
onClick={handleDelete}
loading={loading}
leftSection={<IconTrashFilled size='1rem' />}
color='red'
>
Delete Folder
</Button>
</Stack>
</Modal>
);
}
@@ -1,4 +1,3 @@
import { mutateFolders } from '@/components/file/actions';
import { Response } from '@/lib/api/response';
import type { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
@@ -7,6 +6,8 @@ import { useForm } from '@mantine/form';
import { showNotification } from '@mantine/notifications';
import { IconPencil } from '@tabler/icons-react';
import { useEffect } from 'react';
import { mutateFolder } from '../actions';
export default function EditFolderNameModal({
folder,
onClose,
@@ -28,7 +29,7 @@ export default function EditFolderNameModal({
const onSubmit = async (values: typeof form.values) => {
if (!folder) return;
const { error } = await fetchApi<Response['/api/user/folders/[id]']>(
const { data, error } = await fetchApi<Response['/api/user/folders/[id]']>(
`/api/user/folders/${folder?.id}`,
'PATCH',
{
@@ -42,10 +43,10 @@ export default function EditFolderNameModal({
message: error.error,
});
} else {
mutateFolders();
mutateFolder();
showNotification({
title: 'Folder name updated',
message: 'Folder name has been updated successfully to ' + name,
message: 'Folder name has been updated successfully to ' + data?.name,
});
onClose();
}
@@ -0,0 +1,146 @@
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { buildFolderHierarchy, getDescendantIds } from '@/lib/folderHierarchy';
import { useFolders } from '@/lib/hooks/useFolders';
import { Button, Combobox, InputBase, Modal, Stack, Text, useCombobox } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconFolderSymlink } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { mutateFolder } from '../actions';
export default function MoveFolderModal({
folder,
opened,
onClose,
}: {
folder: Folder | null;
opened: boolean;
onClose: () => void;
}) {
const [selectedParentId, setSelectedParentId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const combobox = useCombobox();
const { data: allFolders } = useFolders(undefined, opened);
const folderOptions = useMemo(() => {
if (!allFolders || !folder) return [];
const descendantIds = getDescendantIds(folder.id, allFolders);
// Exclude the folder being moved and its descendants
const excludeIds = new Set([folder.id, ...descendantIds]);
return buildFolderHierarchy(allFolders, excludeIds);
}, [allFolders, folder]);
const getDisplayValue = () => {
if (selectedParentId === '__root__' || selectedParentId === null) {
return '/ (Root)';
}
const selected = folderOptions.find((f) => f.id === selectedParentId);
return selected?.path || '';
};
if (!folder) {
return null;
}
const handleMove = async () => {
setLoading(true);
const newParentId = selectedParentId === '__root__' ? null : selectedParentId;
const { error } = await fetchApi<Response['/api/user/folders/[id]']>(
`/api/user/folders/${folder.id}`,
'PATCH',
{ parentId: newParentId },
);
setLoading(false);
if (error) {
notifications.show({
title: 'Failed to move folder',
message: error.error,
color: 'red',
});
} else {
notifications.show({
title: 'Folder moved',
message: `${folder.name} has been moved`,
color: 'green',
});
mutateFolder();
onClose();
}
};
return (
<Modal key={folder.id} centered opened={opened} onClose={onClose} title={`Move "${folder.name}"`}>
<Stack gap='sm'>
<Text size='sm' c='dimmed'>
Select a destination folder for this folder.
</Text>
<Combobox
store={combobox}
withinPortal={true}
onOptionSubmit={(value) => {
setSelectedParentId(value);
setSearch(
value === '__root__' ? '/ (Root)' : folderOptions.find((f) => f.id === value)?.path || '',
);
combobox.closeDropdown();
}}
>
<Combobox.Target>
<InputBase
label='Destination'
placeholder='Select a folder'
rightSection={<Combobox.Chevron />}
value={search || getDisplayValue()}
onChange={(event) => {
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
}}
onClick={() => {
combobox.openDropdown();
setSearch('');
}}
onFocus={() => {
combobox.openDropdown();
setSearch('');
}}
onBlur={() => {
combobox.closeDropdown();
setSearch('');
}}
rightSectionPointerEvents='none'
/>
</Combobox.Target>
<Combobox.Dropdown>
<FolderComboboxOptions
folderOptions={folderOptions}
searchValue={search}
additionalOptions={<Combobox.Option value='__root__'>/ (Root)</Combobox.Option>}
/>
</Combobox.Dropdown>
</Combobox>
<Button
onClick={handleMove}
loading={loading}
leftSection={<IconFolderSymlink size='1rem' />}
variant='outline'
>
Move Folder
</Button>
</Stack>
</Modal>
);
}
+16 -8
View File
@@ -1,13 +1,21 @@
import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { Center, Group, Paper, SimpleGrid, Skeleton, Stack, Text, Title } from '@mantine/core';
import { IconLink } from '@tabler/icons-react';
import { IconFolder } from '@tabler/icons-react';
import useSWR from 'swr';
import FolderCard from '../FolderCard';
export default function FolderGridView() {
const { data: folders, isLoading } =
useSWR<Extract<Response['/api/user/folders'], Folder[]>>('/api/user/folders');
export default function FolderGridView({
currentFolderId,
onNavigate,
}: {
currentFolderId: string | null;
onNavigate: (folderId: string | null) => void;
}) {
const queryParam = currentFolderId ? `?parentId=${currentFolderId}` : '?root=true';
const { data: folders, isLoading } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
`/api/user/folders${queryParam}`,
);
return (
<>
@@ -26,7 +34,7 @@ export default function FolderGridView() {
<Skeleton key={i} height={120} animate />
))}
</SimpleGrid>
) : (folders?.length ?? 0 !== 0) ? (
) : (folders?.length ?? 0) !== 0 ? (
<SimpleGrid
my='sm'
spacing='md'
@@ -38,7 +46,7 @@ export default function FolderGridView() {
pos='relative'
>
{folders?.map((folder) => (
<FolderCard key={folder.id} folder={folder} />
<FolderCard key={folder.id} folder={folder} onNavigate={onNavigate} />
))}
</SimpleGrid>
) : (
@@ -46,11 +54,11 @@ export default function FolderGridView() {
<Center>
<Stack>
<Group>
<IconLink size='2rem' />
<IconFolder size='2rem' />
<Title order={2}>No Folders found</Title>
</Group>
<Text size='sm' c='dimmed'>
Create a folder to see it here
{currentFolderId ? 'This folder is empty' : 'Create a folder to see it here'}
</Text>
</Stack>
</Center>
+151 -82
View File
@@ -1,15 +1,15 @@
import RelativeDate from '@/components/RelativeDate';
import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { ActionIcon, Anchor, Box, Checkbox, Group, Tooltip } from '@mantine/core';
import { ActionIcon, Badge, Box, Checkbox, Group, Menu, Text, 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,
IconDots,
IconFileZip,
IconFolder,
IconFolderOpen,
IconFolderSymlink,
IconLock,
IconLockOpen,
IconPencil,
@@ -17,40 +17,136 @@ import {
IconShareOff,
IconTrashFilled,
} from '@tabler/icons-react';
import ViewFilesModal from '../ViewFilesModal';
import EditFolderNameModal from '../EditFolderNameModal';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useMemo, useState } from 'react';
import useSWR from 'swr';
import { copyFolderUrl, editFolderUploads, editFolderVisibility } from '../actions';
import DeleteFolderModal from '../modals/DeleteFolderModal';
import EditFolderNameModal from '../modals/EditFolderNameModal';
import MoveFolderModal from '../modals/MoveFolderModal';
import ViewFilesModal from '../modals/ViewFilesModal';
export default function FolderTableView() {
export const withoutPropagation = (fn: () => void) => (e: React.MouseEvent) => {
e.stopPropagation();
fn();
};
function FolderDotsMenu({
folder,
onNavigate,
setDeleteOpen,
setMoveOpen,
setEditNameOpen,
}: {
folder: Folder;
onNavigate: (folderId: string) => void;
setDeleteOpen: (folder: Folder) => void;
setMoveOpen: (folder: Folder) => void;
setEditNameOpen: (folder: Folder) => void;
}) {
const [opened, setOpened] = useState(false);
return (
<Menu shadow='md' width={200} opened={opened} onChange={setOpened}>
<Menu.Target>
<Tooltip label='More actions'>
<ActionIcon onClick={withoutPropagation(() => setOpened((o) => !o))}>
<IconDots size='1rem' />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
{onNavigate && (
<Menu.Item
leftSection={<IconFolderOpen size='1rem' />}
onClick={withoutPropagation(() => onNavigate(folder.id!))}
>
Open Folder
</Menu.Item>
)}
<Menu.Item
leftSection={<IconFolderSymlink size='1rem' />}
onClick={withoutPropagation(() => setMoveOpen(folder))}
>
Move Folder
</Menu.Item>
<Menu.Item
leftSection={<IconFileZip size='1rem' />}
component='a'
href={`/api/user/folders/${folder.id}/export`}
target='_blank'
onClick={withoutPropagation(() => {})}
>
Export as ZIP
</Menu.Item>
<Menu.Item
leftSection={folder.public ? <IconLock size='1rem' /> : <IconLockOpen size='1rem' />}
onClick={withoutPropagation(() => editFolderVisibility(folder, !folder.public))}
>
{folder.public ? 'Make Private' : 'Make Public'}
</Menu.Item>
<Menu.Item
leftSection={folder.public ? <IconShareOff size='1rem' /> : <IconShare size='1rem' />}
onClick={withoutPropagation(() => editFolderUploads(folder, !folder.allowUploads))}
>
{folder.allowUploads ? 'Disallow anonymous uploads' : 'Allow anonymous uploads'}
</Menu.Item>
<Menu.Item
leftSection={<IconPencil size='1rem' />}
onClick={withoutPropagation(() => setEditNameOpen(folder))}
>
Edit Name
</Menu.Item>
<Menu.Item
leftSection={<IconTrashFilled size='1rem' />}
color='red'
onClick={withoutPropagation(() => setDeleteOpen(folder))}
>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}
export default function FolderTableView({
currentFolderId,
onNavigate,
}: {
currentFolderId: string | null;
onNavigate: (folderId: string | null) => void;
}) {
const clipboard = useClipboard();
const { data, isLoading } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>('/api/user/folders');
const queryParam = currentFolderId ? `?parentId=${currentFolderId}` : '?root=true';
const { data, isLoading } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
`/api/user/folders${queryParam}`,
);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
columnAccessor: 'createdAt',
direction: 'desc',
});
const [sorted, setSorted] = useState<Folder[]>(data ?? []);
const [selectedFolder, setSelectedFolder] = useState<Folder | null>(null);
const [editNameOpen, setEditNameOpen] = useState<Folder | null>(null);
const [moveOpen, setMoveOpen] = useState<Folder | null>(null);
const [deleteOpen, setDeleteOpen] = 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 (
<>
@@ -66,35 +162,45 @@ export default function FolderTableView() {
onClose={() => setEditNameOpen(null)}
/>
<MoveFolderModal opened={!!moveOpen} folder={moveOpen} onClose={() => setMoveOpen(null)} />
<DeleteFolderModal opened={!!deleteOpen} folder={deleteOpen} onClose={() => setDeleteOpen(null)} />
<Box my='sm'>
<DataTable
borderRadius='sm'
withTableBorder
minHeight={200}
records={sorted ?? []}
onRowClick={({ record }) => onNavigate(record.id)}
rowStyle={() => ({ cursor: 'pointer' })}
noRecordsText='No subfolders'
columns={[
{
accessor: 'name',
sortable: true,
render: (folder) =>
folder.public ? (
<Anchor href={`/folder/${folder.id}`} target='_blank'>
{folder.name}
</Anchor>
) : (
folder.name
),
render: (folder) => (
<Group gap='xs'>
<IconFolder size='1rem' />
<Text>{folder.name}</Text>
{(folder._count?.children ?? 0) > 0 && (
<Badge size='xs' variant='light'>
{folder._count?.children} subfolder{(folder._count?.children ?? 0) > 1 ? 's' : ''}
</Badge>
)}
</Group>
),
},
{
accessor: 'public',
sortable: true,
render: (folder) => <Checkbox checked={folder.public} />,
render: (folder) => <Checkbox checked={folder.public} readOnly />,
},
{
accessor: 'allowUploads',
title: 'Uploads?',
sortable: true,
render: (folder) => <Checkbox checked={folder.allowUploads} />,
render: (folder) => <Checkbox checked={folder.allowUploads} readOnly />,
},
{
accessor: 'createdAt',
@@ -113,16 +219,14 @@ export default function FolderTableView() {
textAlign: 'right',
render: (folder) => (
<Group gap='sm' justify='right' wrap='nowrap'>
<Tooltip label='View files'>
<ActionIcon
onClick={(e) => {
e.stopPropagation();
setSelectedFolder(folder);
}}
>
<IconFiles size='1rem' />
</ActionIcon>
</Tooltip>
<FolderDotsMenu
folder={folder}
onNavigate={onNavigate}
setDeleteOpen={setDeleteOpen}
setMoveOpen={setMoveOpen}
setEditNameOpen={setEditNameOpen}
/>
<Tooltip label='Copy folder link'>
<ActionIcon
onClick={(e) => {
@@ -134,47 +238,12 @@ export default function FolderTableView() {
<IconCopy size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label={folder.public ? 'Make private' : 'Make public'}>
<ActionIcon
color={folder.public ? 'blue' : 'gray'}
onClick={(e) => {
e.stopPropagation();
editFolderVisibility(folder, !folder.public);
}}
>
{folder.public ? <IconLockOpen size='1rem' /> : <IconLock size='1rem' />}
</ActionIcon>
</Tooltip>
<Tooltip
label={folder.allowUploads ? 'Disable anonymous uploads' : 'Allow anonymous uploads'}
>
<ActionIcon
color={folder.allowUploads ? 'blue' : 'gray'}
onClick={(e) => {
e.stopPropagation();
editFolderUploads(folder, !folder.allowUploads);
}}
>
{folder.allowUploads ? <IconShareOff size='1rem' /> : <IconShare size='1rem' />}
</ActionIcon>
</Tooltip>
<Tooltip label='Edit Folder Name'>
<ActionIcon
color='blue'
onClick={(e) => {
e.stopPropagation();
setEditNameOpen(folder);
}}
>
<IconPencil size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Delete Folder'>
<ActionIcon
color='red'
onClick={(e) => {
e.stopPropagation();
deleteFolder(folder);
setDeleteOpen(folder);
}}
>
<IconTrashFilled size='1rem' />
View File
View File
+9 -6
View File
@@ -3,7 +3,7 @@ import { Response } from '@/lib/api/response';
import { Invite } from '@/lib/db/models/invite';
import { fetchApi } from '@/lib/fetchApi';
import { useViewStore } from '@/lib/store/view';
import { ActionIcon, Button, Group, Modal, NumberInput, Select, Stack, Title, Tooltip } from '@mantine/core';
import { Button, Group, Modal, NumberInput, Select, Stack, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconTagOff } from '@tabler/icons-react';
@@ -112,11 +112,14 @@ export default function DashboardInvites() {
<Group>
<Title>Invites</Title>
<Tooltip label='Create a new invite'>
<ActionIcon variant='outline' onClick={() => setOpen(true)}>
<IconPlus size='1rem' />
</ActionIcon>
</Tooltip>
<Button
variant='outline'
size='compact-sm'
leftSection={<IconPlus size='1rem' />}
onClick={() => setOpen(true)}
>
Create
</Button>
<GridTableSwitcher type='invites' />
</Group>
View File
+13 -17
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 (
<>

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