Compare commits

..

71 Commits

Author SHA1 Message Date
diced
0ac81c887e feat: CHUNKS_ENABLED config 2023-04-03 22:36:32 -07:00
diced
5178f762eb fix: use temp_directory instead of tmpdir() 2023-03-31 22:33:37 -07:00
dicedtomato
bcc2d673dd Merge branch 'trunk' into feature/offload-uploads 2023-03-31 22:32:15 -07:00
Jayvin Hernandez
bf40fa9cd2 feat: many things (#351)
* remove source from final image

* move check state to ClearStorage

* use inspect for fancy colors

* newlines are now possible! yay!

* Catch user's leave if uploading

* feat?: Temp directory can be specified by the user.
Default is /tmp/zipline (or os equivalent)

* fix: ignore onDash config, use only ?compress query

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-03-31 22:25:00 -07:00
diced
bc58c1b56e fix: milestone again again again again again again 2023-03-31 22:12:13 -07:00
diced
c57a6e1700 fix: milestone again again again again again 2023-03-31 22:07:17 -07:00
diced
8649a489d8 fix: milestone again again again again 2023-03-31 22:05:59 -07:00
diced
40f29907c7 fix: milestone again again again 2023-03-31 21:55:26 -07:00
diced
34005ece43 fix: milestone again again 2023-03-31 21:53:30 -07:00
diced
8e6fc1e8a3 fix: milestone again 2023-03-31 21:49:50 -07:00
diced
065f44b145 fix: milestone 2023-03-31 21:41:19 -07:00
diced
e707685da3 feat: offloaded chunked uploads 2023-03-31 21:32:38 -07:00
diced
e5a07f568d fix: update milestone action 2023-03-27 16:48:56 -07:00
dicedtomato
a728d71da1 Merge branch '3.x' into trunk 2023-03-26 20:46:00 -07:00
diced
91e468791e feat(3.7.0): version 2023-03-26 20:45:01 -07:00
dicedtomato
169a2ea562 Revert "Release 3.7.0 (#328)" (#347)
This reverts commit f9060f8ae7.
2023-03-26 20:40:41 -07:00
dicedtomato
f9060f8ae7 Release 3.7.0 (#328)
* fix: oauthId optional

* fix: remove optional

* hotfix: make oauthid optional (#249)

* hotfix: fallback oauth find (#250)

* fix: add a forgotten ? to schema

* fix: catch null at other places (#252)

* fix: forgor (#253)

* Fix root url & uploader stuff (#254)

* fix: uploader route as root won't be broken

* fix: fix broken url route for when on root

* fix: catch hopefully the most of the edge cases (#251)

* fix: catch hopefully the most of the edge cases

* fix: invite only, fools

* feat: tsup, small fixes

* fix: #264

* fix: urls handle empty strings

* fix: remove esbuild

* fix: do not mutate res #266

* feat: new embed method

* fix: overwrite tmp ss flameshot

* fix: overrides for uploading

* refactor: chart.js -> recharts

* feat: download query on /r/

* fix: better icons on file vie2

* feat: ability to generate url shorten config

* fix: sxcu name

* fix: react hooks error

* feat: ability to insert tabs on Tab keypress

* feat: overhaul image upload

* fix: clean

* fix: group icons vertically

* fix: embeds not showing up

* fix: docker stuff

* feat(3.7.0-rc1): version

* fix: cors for files (#257)

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>

* refactor: many columns/tables in prisma

* feat: keep original name #247

* fix: ability to gen with original-name

* fix: type error

* fix: no name on dashboard

* feat(v3.7.0-rc2): version

* fix: sharex DestinationType

* fix: ensureDatabaseExists args

* fix: sharex config

* fix: #277 #272

* fix: 🐛 Add Menu component as parent

* refactor: popover -> menu

Co-authored-by: IceToast <>
Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>

* fix: add a "skip" for fresh db's (#274)

* fix: add a "skip" for fresh db's

* fix: trimming

* fix: elevate logging!

* fix: allow more variables on view

* fix: optimize docker image

* fix:  root url for upload and shorten (#255)

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>

* fix: /app -> /zipline

* feat(v3.7.0-rc3): folders for files

* fix: use `name` instead of `file` (#281)

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>

* feat: search+create for folder select (#283)

* feat?: Search for the folder to add.
Also you can create a folder right from the file, rather than being redirected.

* woops wrong import

* fix: return null for no string in parser (#285)

* feat: use ENTRYPOINT in docker (#286)

* :3

* Update Dockerfile

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>

* Update Dockerfile

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>

* Update Dockerfile

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>

* test

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>

* fix: set password to actual text value

* fix: url encode password query

* fix: entrypoint executable (#289)

* feat: override domain header

* fix: random domains

* feat: better version checking

* feat: public folders

* fix: dates #278

* feat: devcontainers for codespaces, etc.

* experiment with devcontainer.json

* introduce a docker-compose for devcontainer

* Devcontainers!

* version pop

* Port labeling and a complimentary env variable

* see it to believe it

* Update .devcontainer/devcontainer.json

* Update .devcontainer/devcontainer.json

* Update .devcontainer/docker-compose.yml

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>

* fix: spaces and route fixes (#294)

* fix: spaces and route fixes

* fix: shorten url response

* feat: better version checking

* fix: use special characters should work

If it doesn't, better call saul

* save that extra byte

* fix: returning protocol again in domain

unrelated to this pr but whatever

* fix: above ^

* Rename shorten.tsv to shorten.ts

---------

Co-authored-by: diced <pranaco2@gmail.com>
Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>

* fix: #296

* fix: show files per user (#299)

* feat: clearing orphaned files (#303)

* fix: default public folder (docker)

* feat: seperate discord webhooks (shorten/upload) (#260)

* fix: title for folders

* fix: clipboard & 2fa improvements

A workaround that shows the content that would have been copied if `navigator.clipboard` is unavailable for whatever reason.

2FA input autofocuses & submits on enter.

* fix: revamp uploaded file modal

* fix: revamp mobile ui

* feat: more functionality within files table

* feat: clear zero byte files script

* feat: logger improvements
- Timestamp is gray
- removed colorette dependency
- introduction of LOGGER_FILTERS

* chore: update deps

* feat(v3.7.0-rc4): version

* fix: show warning when password protect

* fix: fix (#310)

* Muted audio by default!

* Code renderin'

* not but still decently standard files being viewable

* reserved routes

* Update validateConfig.ts

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>

* feat: file size (#308)

* feat: baseline support for file sizes

* feat: script to add file sizes

* fix: #311

* chore: update to mantine@6

* refactor: remove old File.tsx

* feat: initial move to mantine v6

* feat: use api option

* remove: useless size modifier

* fix: user button

* feat: use pininput for 2fa

* fix: breaking changes in migrating mantine v6

---------

Co-authored-by: TacticalCoderJay <gogojayvin923@gmail.com>

* feat: add size to datatable

* fix: null on non originalName

* fix: allow download query on non raw

* fix: undef file

* fix: spacingg between count_by_user

* feat: new ui for shortened urls

* fix: spacing within appshell/paper

* feat: new login page

* feat: reorganize menu

* feat: keyboard spotlight

* feat: tabler icons

* fix: remove feather import

* fix: update 2fa enabled appropriately & delete files (#315)

* fix: update 2fa enabled appropriately

* fix: a proper delete

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>

* feat(v3.7.0-rc5): version

* feat: add feature request "contact_link"

* feat: multiple stuffs

* feat: gfycat url #322

* feat: gfycat attribution

* feat(3.7.0-rc6): version

* fix: type cast

* feat: list view for urls, invites, users: #302

* refactor: docker-compose -> docker compose

* fix: open folder in new tab

* fix: save list-view setting to localStorage

* fix: Bug: URLs list view #330

* fix: #332

* fix: #331

* fix: #333

* feat: link to view gallery (icon)

* fix: clean up Anchors

* refactor: new eslint changes

* fix: fine tune devcontainer (#329)

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>

* fix: FileModal scrollbars

* fix: dynamically import katex

* fix: remove rogue console.log

* fix: open folder onRowClick

* fix: filter on usePaginatedFiles

* fix: icon sizes

* fix: paste listener

* feat(actions): auto-assign milestone

* feat(v3.7.0-rc7): version

* fix: #339

* fix: resetting avatars

* feat: new icons / oauth icons changed

* feat: UPLOADER_ASSUME_MIMETYPES (#337)

* fix: any instance of #342

* fix: any instance of #345

* fix: make tables take entire vh

* chore: update deps

* fix: add bigger sizeLimit

* fix: token exposed on view/[id]

---------

Co-authored-by: Jayvin Hernandez <gogojayvin923@gmail.com>
Co-authored-by: IceToast <54889359+IceToast@users.noreply.github.com>
Co-authored-by: IThundxr <harshdhaliwal9767@gmail.com>
Co-authored-by: IThundxr <contact@ithundxr.dev>
2023-03-26 20:40:01 -07:00
diced
d379bf8b1c fix: token exposed on view/[id] 2023-03-26 12:14:41 -07:00
diced
67b71ceffe fix: add bigger sizeLimit 2023-03-26 00:17:59 -07:00
diced
eb6929b889 chore: update deps 2023-03-26 00:03:10 -07:00
diced
d7299f8220 fix: make tables take entire vh 2023-03-25 23:39:16 -07:00
diced
1ed267ad94 fix: any instance of #345 2023-03-25 23:36:16 -07:00
diced
40a0cce3e8 fix: any instance of #342 2023-03-25 23:33:37 -07:00
diced
556aafaad3 feat: UPLOADER_ASSUME_MIMETYPES (#337) 2023-03-24 17:57:56 -07:00
diced
fdc7901eff feat: new icons / oauth icons changed 2023-03-24 17:56:53 -07:00
diced
9632399f5d fix: resetting avatars 2023-03-24 17:24:15 -07:00
diced
cc8a5411ab fix: #339 2023-03-24 17:20:33 -07:00
diced
12bb804e6a feat(v3.7.0-rc7): version 2023-03-22 20:27:52 -07:00
diced
3a27f31a03 feat(actions): auto-assign milestone 2023-03-22 19:41:06 -07:00
diced
37e7ad840c fix: paste listener 2023-03-22 19:36:26 -07:00
diced
c57a1ea326 fix: icon sizes 2023-03-21 20:06:46 -07:00
diced
12d5d5f08f fix: filter on usePaginatedFiles 2023-03-21 19:57:16 -07:00
diced
e7cf44e8e9 fix: open folder onRowClick 2023-03-21 19:53:59 -07:00
diced
a81f797266 fix: remove rogue console.log 2023-03-21 19:51:02 -07:00
diced
6ada79017a fix: dynamically import katex 2023-03-21 19:46:13 -07:00
diced
bdf34bbbbf fix: FileModal scrollbars 2023-03-21 19:31:45 -07:00
Jayvin Hernandez
c0d1b3d887 fix: fine tune devcontainer (#329)
Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-03-21 18:01:02 -07:00
diced
1b505d463c refactor: new eslint changes 2023-03-21 17:53:22 -07:00
diced
25606a80ec fix: clean up Anchors 2023-03-21 17:09:50 -07:00
diced
8b540bff62 feat: link to view gallery (icon) 2023-03-21 16:36:47 -07:00
diced
8a2064e09d fix: #333 2023-03-21 16:30:01 -07:00
diced
1f0fb32b9b fix: #331 2023-03-20 22:50:28 -07:00
diced
3cbc345c00 fix: #332 2023-03-20 22:49:24 -07:00
diced
3c66c18c77 fix: Bug: URLs list view #330 2023-03-20 22:37:30 -07:00
diced
bcc816ea55 fix: save list-view setting to localStorage 2023-03-20 22:36:10 -07:00
diced
eb2713bc23 fix: open folder in new tab 2023-03-20 22:25:56 -07:00
diced
bcd68ae98b refactor: docker-compose -> docker compose 2023-03-19 16:48:08 -07:00
diced
d1a486ac1f feat: list view for urls, invites, users: #302 2023-03-19 16:44:04 -07:00
diced
0d36f5f091 fix: type cast 2023-03-18 21:57:41 -07:00
diced
3d5cdf50e6 feat(3.7.0-rc6): version 2023-03-18 21:54:20 -07:00
diced
1e81822c11 feat: gfycat attribution 2023-03-18 21:52:14 -07:00
diced
f8cd847588 feat: gfycat url #322 2023-03-18 21:48:19 -07:00
Jayvin Hernandez
5b9b454330 feat: multiple stuffs 2023-03-18 20:52:04 -07:00
dicedtomato
9c5b3f60d5 feat: add feature request "contact_link" 2023-03-09 20:51:07 -08:00
diced
d83c255382 feat(v3.7.0-rc5): version 2023-03-04 22:08:09 -08:00
Jayvin Hernandez
656b900256 fix: update 2fa enabled appropriately & delete files (#315)
* fix: update 2fa enabled appropriately

* fix: a proper delete

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-03-04 22:07:29 -08:00
diced
a16b516163 fix: remove feather import 2023-03-04 21:56:47 -08:00
diced
6d8e66478c feat: tabler icons 2023-03-04 21:51:58 -08:00
diced
4428555762 feat: keyboard spotlight 2023-03-04 19:57:09 -08:00
diced
463e91c3bd feat: reorganize menu 2023-03-04 18:48:05 -08:00
diced
1e37f06ab6 feat: new login page 2023-03-04 18:33:30 -08:00
diced
3af3ba69f5 fix: spacing within appshell/paper 2023-03-04 18:11:11 -08:00
diced
0adc07ac38 feat: new ui for shortened urls 2023-03-04 18:04:22 -08:00
diced
4fe4faa202 fix: spacingg between count_by_user 2023-03-04 17:58:35 -08:00
diced
4912a872e0 fix: undef file 2023-03-04 17:51:40 -08:00
diced
ac05d82e3a fix: allow download query on non raw 2023-03-04 17:50:14 -08:00
diced
6583f1114c fix: null on non originalName 2023-03-04 17:47:40 -08:00
diced
e2673fa9e1 feat: add size to datatable 2023-03-04 17:20:49 -08:00
dicedtomato
bc4b528ac6 chore: update to mantine@6
* refactor: remove old File.tsx

* feat: initial move to mantine v6

* feat: use api option

* remove: useless size modifier

* fix: user button

* feat: use pininput for 2fa

* fix: breaking changes in migrating mantine v6

---------

Co-authored-by: TacticalCoderJay <gogojayvin923@gmail.com>
2023-03-04 17:08:43 -08:00
dicedtomato
986858345e fix: #311 2023-03-04 04:52:00 +00:00
dicedtomato
912e439645 feat: file size (#308)
* feat: baseline support for file sizes

* feat: script to add file sizes
2023-03-03 20:40:28 -08:00
167 changed files with 6787 additions and 2214 deletions

10
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-18
RUN usermod -l zipline node \
&& groupmod -n zipline node \
&& usermod -d /home/zipline zipline \
&& echo "zipline ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers.d/zipline \
&& chmod 0440 /etc/sudoers.d/zipline \
&& sudo apt-get update && apt-get install gnupg2 -y
USER zipline

View File

@@ -2,12 +2,15 @@
"name": "Zipline Codespace",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"forwardPorts": [3000, 5432],
"workspaceFolder": "/zipline",
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {},
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {},
"ghcr.io/devcontainers/features/node:1": {}
"ghcr.io/devcontainers/features/common-utils:2": {
"username": "zipline"
},
"ghcr.io/devcontainers/features/docker-in-docker:1": {
"dockerDashComposeVersion": "v2",
"installDockerBuildx": true
}
},
"customizations": {
"vscode": {
@@ -20,22 +23,34 @@
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"files.autoSave": "afterDelay"
"files.autoSave": "afterDelay",
"terminal.integrated.persistentSessionReviveProcess": "never",
"terminal.integrated.defaultProfile.linux": "zsh",
"terminal.integrated.profiles.linux": {
"zsh": {
"path": "/bin/zsh",
"env": {
"ZSH_THEME": "devcontainers"
}
}
}
},
"extensions": ["prisma.prisma", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
}
},
"remoteUser": "zipline",
"updateRemoteUserUID": true,
"remoteEnv": {
"CORE_DATABASE_URL": "postgres://postgres:postgres@localhost/zip10"
},
"portsAttributes": {
"3000": {
"label": "Zipline",
"3000": {
"label": "Zipline",
"onAutoForward": "openBrowser"
},
"5432": {
"label": "Postgres"
}
}
},
"5432": {
"label": "Postgres"
}
},
"postCreateCommand": "sudo chown -R zipline:zipline /zipline && yarn install"
}

View File

@@ -1,12 +1,14 @@
version: '3.8'
services:
app:
image: mcr.microsoft.com/vscode/devcontainers/javascript-node:0-18
build:
context: ./
dockerfile: Dockerfile
volumes:
- ..:/workspace:cached
network_mode: service:db
- ../:/zipline:cached
- uploads:/zipline/uploads
- node_modules:/zipline/node_modules
command: sleep infinity
user: zipline
db:
image: postgres:latest
restart: unless-stopped
@@ -19,4 +21,5 @@ services:
volumes:
pg_data:
uploads:
node_modules:

7
.eslintignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
.yarn
.devcontainer
.github
.next
.vscode

View File

@@ -1,5 +1,13 @@
{
"extends": ["next", "next/core-web-vitals", "plugin:prettier/recommended"],
"root": true,
"extends": [
"next",
"next/core-web-vitals",
"plugin:prettier/recommended",
"plugin:@typescript-eslint/recommended"
],
"plugins": ["unused-imports", "@typescript-eslint"],
"parser": "@typescript-eslint/parser",
"rules": {
"linebreak-style": ["error", "unix"],
"quotes": [
@@ -28,6 +36,14 @@
"react/style-prop-object": "warn",
"@next/next/no-img-element": "off",
"jsx-a11y/alt-text": "off",
"react/display-name": "off"
"react/display-name": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"error",
{ "vars": "all", "varsIgnorePattern": "^_", "args": "after-used", "argsIgnorePattern": "^_" }
],
"@typescript-eslint/ban-ts-comment": "off"
}
}

View File

@@ -1,5 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Feature Request
url: https://github.com/diced/zipline/discussions/new?category=ideas&title=Your%20breif%20description%20here&labels=feature
about: Ask for a new feature
- name: Zipline Discord
url: https://discord.gg/EAhCRfGxCF
about: Ask for help with anything related to Zipline!

31
.github/workflows/milestone.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: 'Issue/PR Milestones'
on:
pull_request_target:
types: [opened, reopened]
issues:
types: [opened, reopened]
permissions:
issues: write
checks: write
contents: read
pull-requests: write
jobs:
set:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/github-script@v6
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const milestone = 3
github.rest.issues.update({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
milestone
})

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
package-lock=false

View File

@@ -69,7 +69,8 @@ COPY --from=builder /zipline/node_modules/@prisma/client ./node_modules/@prisma/
# Copy Startup Script
COPY docker-entrypoint.sh /zipline
# Make Startup Script Executable
RUN chmod a+x /zipline/docker-entrypoint.sh
RUN chmod a+x /zipline/docker-entrypoint.sh && rm -rf /zipline/src
# Set the entrypoint to the startup script
ENTRYPOINT ["tini", "--", "/zipline/docker-entrypoint.sh"]

View File

@@ -25,7 +25,7 @@ A ShareX/file upload server that is easy to use, packed with features, and with
- Password Protected Uploads
- URL shortening
- Text uploading
- URL Formats (uuid, dates, random alphanumeric, original name, zws)
- URL Formats (uuid, dates, random alphanumeric, original name, zws, gfycat -> [animals](https://assets.gfycat.com/animals) [adjectives](https://assets.gfycat.com/adjectives))
- Discord embeds (OG metadata)
- Gallery viewer, and multiple file format support
- Code highlighting
@@ -35,7 +35,16 @@ A ShareX/file upload server that is easy to use, packed with features, and with
- User invites
- File Chunking (for large files)
- File deletion once it reaches a certain amount of views
- Easy setup instructions on [docs](https://zipl.vercel.app/) (One command install `docker-compose up -d`)
- Easy setup instructions on [docs](https://zipl.vercel.app/) (One command install `docker compose up -d`)
<details>
<summary>View upstream documentation</summary>
The website below provides documentation for more up-to-date features with the upstream branch. The normal documentation is for the latest release and is not updated unless a new release is made.
[https://trunk.zipline.diced.tech/](https://trunk.zipline.diced.tech/)
</details>
<details>
<summary><h2>Screenshots (click)</h2></summary>
@@ -51,13 +60,13 @@ A ShareX/file upload server that is easy to use, packed with features, and with
## Install & run with Docker
This section requires [Docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/).
This section requires [Docker](https://docs.docker.com/get-docker/) and [docker compose](https://docs.docker.com/compose/install/).
```shell
git clone https://github.com/diced/zipline
cd zipline
docker-compose up -d
docker compose up -d
```
### After installing

View File

@@ -1,6 +1,6 @@
{
"name": "zipline",
"version": "3.7.0-rc4",
"version": "3.7.0",
"license": "MIT",
"scripts": {
"dev": "npm-run-all build:server dev:run",
@@ -14,11 +14,11 @@
"migrate:dev": "prisma migrate dev --create-only",
"start": "node dist",
"lint": "next lint",
"docker:up": "docker-compose up",
"docker:down": "docker-compose down",
"docker:build-dev": "docker-compose --file docker-compose.dev.yml up --build",
"docker:up-dev": "docker-compose --file docker-compose.dev.yml up",
"docker:down-dev": "docker-compose --file docker-compose.dev.yml down",
"compose:up": "docker compose up",
"compose:down": "docker compose down",
"compose:build-dev": "docker compose --file docker-compose.dev.yml up --build",
"compose:up-dev": "docker compose --file docker-compose.dev.yml up",
"compose:down-dev": "docker compose --file docker-compose.dev.yml down",
"scripts:read-config": "node --enable-source-maps dist/scripts/read-config",
"scripts:import-dir": "node --enable-source-maps dist/scripts/import-dir",
"scripts:list-users": "node --enable-source-maps dist/scripts/list-users",
@@ -29,66 +29,70 @@
"dependencies": {
"@emotion/react": "^11.10.6",
"@emotion/server": "^11.10.0",
"@mantine/core": "^5.10.5",
"@mantine/dropzone": "^5.10.5",
"@mantine/form": "^5.10.5",
"@mantine/hooks": "^5.10.5",
"@mantine/modals": "^5.10.5",
"@mantine/next": "^5.10.5",
"@mantine/notifications": "^5.10.5",
"@mantine/prism": "^5.10.5",
"@mantine/core": "^6.0.4",
"@mantine/dropzone": "^6.0.4",
"@mantine/form": "^6.0.4",
"@mantine/hooks": "^6.0.4",
"@mantine/modals": "^6.0.4",
"@mantine/next": "^6.0.4",
"@mantine/notifications": "^6.0.4",
"@mantine/prism": "^6.0.4",
"@mantine/spotlight": "^6.0.4",
"@prisma/client": "^4.10.1",
"@prisma/internals": "^4.10.1",
"@prisma/migrate": "^4.10.1",
"@sapphire/shapeshift": "^3.8.1",
"@tanstack/react-query": "^4.24.10",
"@tabler/icons-react": "^2.11.0",
"@tanstack/react-query": "^4.28.0",
"argon2": "^0.30.3",
"cookie": "^0.5.0",
"dayjs": "^1.11.7",
"dotenv": "^16.0.3",
"dotenv-expand": "^10.0.0",
"exiftool-vendored": "^21.2.0",
"fastify": "^4.13.0",
"fastify": "^4.15.0",
"fastify-plugin": "^4.5.0",
"fflate": "^0.7.4",
"find-my-way": "^7.5.0",
"find-my-way": "^7.6.0",
"katex": "^0.16.4",
"mantine-datatable": "^1.8.6",
"minio": "^7.0.32",
"mantine-datatable": "^2.2.6",
"minio": "^7.0.33",
"ms": "canary",
"multer": "^1.4.5-lts.1",
"next": "^13.2.1",
"next": "^13.2.4",
"otplib": "^12.0.1",
"prisma": "^4.10.1",
"prismjs": "^1.29.0",
"qrcode": "^1.5.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-feather": "^2.0.10",
"react-markdown": "^8.0.5",
"recharts": "^2.4.3",
"recoil": "^0.7.6",
"react-markdown": "^8.0.6",
"recharts": "^2.5.0",
"recoil": "^0.7.7",
"remark-gfm": "^3.0.1",
"sharp": "^0.31.3"
"sharp": "^0.32.0"
},
"devDependencies": {
"@types/cookie": "^0.5.1",
"@types/katex": "^0.16.0",
"@types/minio": "^7.0.16",
"@types/minio": "^7.0.17",
"@types/multer": "^1.4.7",
"@types/node": "^18.14.2",
"@types/node": "^18.15.10",
"@types/qrcode": "^1.5.0",
"@types/react": "^18.0.28",
"@types/react": "^18.0.29",
"@types/sharp": "^0.31.1",
"@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^5.56.0",
"cross-env": "^7.0.3",
"eslint": "^8.35.0",
"eslint-config-next": "^13.2.1",
"eslint-config-prettier": "^8.6.0",
"eslint": "^8.36.0",
"eslint-config-next": "^13.2.4",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-unused-imports": "^2.0.0",
"npm-run-all": "^4.1.5",
"prettier": "^2.8.4",
"tsup": "^6.6.3",
"typescript": "^4.9.5"
"prettier": "^2.8.7",
"tsup": "^6.7.0",
"typescript": "^5.0.2"
},
"repository": {
"type": "git",

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- You are about to drop the column `format` on the `File` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "File" DROP COLUMN "format";
-- DropEnum
DROP TYPE "FileNameFormat";

View File

@@ -0,0 +1,18 @@
-- CreateEnum
CREATE TYPE "ProcessingStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETE');
-- CreateTable
CREATE TABLE "IncompleteFile" (
"id" SERIAL NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"status" "ProcessingStatus" NOT NULL,
"chunks" INTEGER NOT NULL,
"chunksComplete" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
"data" JSONB NOT NULL,
CONSTRAINT "IncompleteFile_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "IncompleteFile" ADD CONSTRAINT "IncompleteFile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -8,23 +8,24 @@ generator client {
}
model User {
id Int @id @default(autoincrement())
username String
password String?
avatar String?
token String
administrator Boolean @default(false)
superAdmin Boolean @default(false)
systemTheme String @default("system")
embed Json @default("{}")
ratelimit DateTime?
totpSecret String?
domains String[]
oauth OAuth[]
files File[]
urls Url[]
Invite Invite[]
Folder Folder[]
id Int @id @default(autoincrement())
username String
password String?
avatar String?
token String
administrator Boolean @default(false)
superAdmin Boolean @default(false)
systemTheme String @default("system")
embed Json @default("{}")
ratelimit DateTime?
totpSecret String?
domains String[]
oauth OAuth[]
files File[]
urls Url[]
Invite Invite[]
Folder Folder[]
IncompleteFile IncompleteFile[]
}
model Folder {
@@ -40,13 +41,6 @@ model Folder {
files File[]
}
enum FileNameFormat {
UUID
DATE
RANDOM
NAME
}
model File {
id Int @id @default(autoincrement())
name String
@@ -61,7 +55,6 @@ model File {
embed Boolean @default(false)
password String?
invisible InvisibleFile?
format FileNameFormat @default(RANDOM)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId Int?
@@ -134,3 +127,23 @@ enum OauthProviders {
GITHUB
GOOGLE
}
model IncompleteFile {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
status ProcessingStatus
chunks Int
chunksComplete Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
data Json
}
enum ProcessingStatus {
PENDING
PROCESSING
COMPLETE
}

1501
public/adjectives.txt Normal file

File diff suppressed because it is too large Load Diff

1750
public/animals.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
import { Anchor } from '@mantine/core';
import Link from 'next/link';
export default function AnchorNext({ href, ...others }) {
return <Anchor component={Link} href={href} {...others} />;
}

View File

@@ -1,16 +1,15 @@
import { createStyles, MantineSize, Textarea } from '@mantine/core';
import { createStyles, Textarea } from '@mantine/core';
import { useEffect } from 'react';
const useStyles = createStyles((theme, { size }: { size: MantineSize }) => ({
const useStyles = createStyles(() => ({
input: {
fontFamily: 'monospace',
fontSize: theme.fn.size({ size, sizes: theme.fontSizes }) - 2,
height: '80vh',
},
}));
export default function CodeInput({ ...props }) {
const { classes } = useStyles({ size: 'md' }, { name: 'CodeInput' });
const { classes } = useStyles(null, { name: 'CodeInput' });
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {

View File

@@ -1,398 +0,0 @@
import {
ActionIcon,
Card,
Group,
LoadingOverlay,
Modal,
Select,
SimpleGrid,
Stack,
Text,
Title,
Tooltip,
} from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import useFetch from 'hooks/useFetch';
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
import { useFolders } from 'lib/queries/folders';
import { bytesToHuman } from 'lib/utils/bytes';
import { relativeTime } from 'lib/utils/client';
import { useState } from 'react';
import {
CalendarIcon,
ClockIcon,
CopyIcon,
CrossIcon,
DeleteIcon,
DownloadIcon,
ExternalLinkIcon,
EyeIcon,
HardDriveIcon,
FileIcon,
FolderMinusIcon,
FolderPlusIcon,
HashIcon,
ImageIcon,
InfoIcon,
StarIcon,
} from './icons';
import MutedText from './MutedText';
import Type from './Type';
export function FileMeta({ Icon, title, subtitle, ...other }) {
return other.tooltip ? (
<Group>
<Icon size={24} />
<Tooltip label={other.tooltip}>
<Stack spacing={1}>
<Text>{title}</Text>
<MutedText size='md'>{subtitle}</MutedText>
</Stack>
</Tooltip>
</Group>
) : (
<Group>
<Icon size={24} />
<Stack spacing={1}>
<Text>{title}</Text>
<MutedText size='md'>{subtitle}</MutedText>
</Stack>
</Group>
);
}
export default function File({
image,
disableMediaPreview,
exifEnabled,
refreshImages,
reducedActions = false,
}) {
const [open, setOpen] = useState(false);
const [overrideRender, setOverrideRender] = useState(false);
const deleteFile = useFileDelete();
const favoriteFile = useFileFavorite();
const clipboard = useClipboard();
const folders = useFolders();
const loading = deleteFile.isLoading || favoriteFile.isLoading;
const handleDelete = async () => {
deleteFile.mutate(image.id, {
onSuccess: () => {
showNotification({
title: 'File Deleted',
message: '',
color: 'green',
icon: <DeleteIcon />,
});
},
onError: (res: any) => {
showNotification({
title: 'Failed to delete file',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
},
onSettled: () => {
setOpen(false);
},
});
};
const handleCopy = () => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${image.url}`);
setOpen(false);
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
};
const handleFavorite = async () => {
favoriteFile.mutate(
{ id: image.id, favorite: !image.favorite },
{
onSuccess: () => {
showNotification({
title: 'Image is now ' + (!image.favorite ? 'favorited' : 'unfavorited'),
message: '',
icon: <StarIcon />,
});
},
onError: (res: any) => {
showNotification({
title: 'Failed to favorite file',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
},
}
);
};
const inFolder = image.folderId;
const refresh = () => {
refreshImages();
folders.refetch();
};
const removeFromFolder = async () => {
const res = await useFetch('/api/user/folders/' + image.folderId, 'DELETE', {
file: Number(image.id),
});
refresh();
if (!res.error) {
showNotification({
title: 'Removed from folder',
message: res.name,
color: 'green',
icon: <FolderMinusIcon />,
});
} else {
showNotification({
title: 'Failed to remove from folder',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
}
};
const addToFolder = async (t) => {
const res = await useFetch('/api/user/folders/' + t, 'POST', {
file: Number(image.id),
});
refresh();
if (!res.error) {
showNotification({
title: 'Added to folder',
message: res.name,
color: 'green',
icon: <FolderPlusIcon />,
});
} else {
showNotification({
title: 'Failed to add to folder',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
}
};
const createFolder = (t) => {
useFetch('/api/user/folders', 'POST', {
name: t,
add: [Number(image.id)],
}).then((res) => {
refresh();
if (!res.error) {
showNotification({
title: 'Created & added to folder',
message: res.name,
color: 'green',
icon: <FolderPlusIcon />,
});
} else {
showNotification({
title: 'Failed to create folder',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
}
});
return { value: t, label: t };
};
return (
<>
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>{image.name}</Title>} size='xl'>
<LoadingOverlay visible={loading} />
<Stack>
<Type
file={image}
src={`/r/${encodeURI(image.name)}`}
alt={image.name}
popup
sx={{ minHeight: 200 }}
style={{ minHeight: 200 }}
disableMediaPreview={false}
overrideRender={overrideRender}
setOverrideRender={setOverrideRender}
/>
<SimpleGrid
my='md'
cols={3}
breakpoints={[
{ maxWidth: 600, cols: 1 },
{ maxWidth: 900, cols: 2 },
{ maxWidth: 1200, cols: 3 },
]}
>
<FileMeta Icon={FileIcon} title='Name' subtitle={image.name} />
<FileMeta Icon={ImageIcon} title='Type' subtitle={image.mimetype} />
<FileMeta Icon={HardDriveIcon} title='Size' subtitle={bytesToHuman(image.size || 0)} />
<FileMeta Icon={EyeIcon} title='Views' subtitle={image?.views?.toLocaleString()} />
{image.maxViews && (
<FileMeta
Icon={EyeIcon}
title='Max views'
subtitle={image?.maxViews?.toLocaleString()}
tooltip={`This file will be deleted after being viewed ${image?.maxViews?.toLocaleString()} times.`}
/>
)}
<FileMeta
Icon={CalendarIcon}
title='Uploaded'
subtitle={relativeTime(new Date(image.createdAt))}
tooltip={new Date(image?.createdAt).toLocaleString()}
/>
{image.expiresAt && !reducedActions && (
<FileMeta
Icon={ClockIcon}
title='Expires'
subtitle={relativeTime(new Date(image.expiresAt))}
tooltip={new Date(image.expiresAt).toLocaleString()}
/>
)}
<FileMeta Icon={HashIcon} title='ID' subtitle={image.id} />
</SimpleGrid>
</Stack>
<Group position='apart' my='md'>
<Group position='left'>
{exifEnabled && !reducedActions && (
<Tooltip label='View Metadata'>
<ActionIcon
color='blue'
variant='filled'
onClick={() => window.open(`/dashboard/metadata/${image.id}`, '_blank')}
>
<InfoIcon />
</ActionIcon>
</Tooltip>
)}
{reducedActions ? null : inFolder && !folders.isLoading ? (
<Tooltip
label={`Remove from folder "${
folders.data.find((f) => f.id === image.folderId)?.name ?? ''
}"`}
>
<ActionIcon
color='red'
variant='filled'
onClick={removeFromFolder}
loading={folders.isLoading}
>
<FolderMinusIcon />
</ActionIcon>
</Tooltip>
) : (
<Tooltip label='Add to folder'>
<Select
onChange={addToFolder}
placeholder='Add to folder'
data={[
...(folders.data ? folders.data : []).map((folder) => ({
value: String(folder.id),
label: `${folder.id}: ${folder.name}`,
})),
]}
searchable
creatable
getCreateLabel={(query) => `Create folder "${query}"`}
onCreate={createFolder}
/>
</Tooltip>
)}
</Group>
<Group position='right'>
{reducedActions ? null : (
<>
<Tooltip label='Delete file'>
<ActionIcon color='red' variant='filled' onClick={handleDelete}>
<DeleteIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={image.favorite ? 'Unfavorite' : 'Favorite'}>
<ActionIcon
color={image.favorite ? 'yellow' : 'gray'}
variant='filled'
onClick={handleFavorite}
>
<StarIcon />
</ActionIcon>
</Tooltip>
</>
)}
<Tooltip label='Open in new tab'>
<ActionIcon color='blue' variant='filled' onClick={() => window.open(image.url, '_blank')}>
<ExternalLinkIcon />
</ActionIcon>
</Tooltip>
<Tooltip label='Copy URL'>
<ActionIcon color='blue' variant='filled' onClick={handleCopy}>
<CopyIcon />
</ActionIcon>
</Tooltip>
<Tooltip label='Download'>
<ActionIcon
color='blue'
variant='filled'
onClick={() => window.open(`/r/${encodeURI(image.name)}?download=true`, '_blank')}
>
<DownloadIcon />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Modal>
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
<Card.Section>
<LoadingOverlay visible={loading} />
<Type
file={image}
sx={{
minHeight: 200,
maxHeight: 320,
fontSize: 70,
width: '100%',
cursor: 'pointer',
}}
style={{
minHeight: 200,
maxHeight: 320,
fontSize: 70,
width: '100%',
cursor: 'pointer',
}}
src={`/r/${encodeURI(image.name)}`}
alt={image.name}
onClick={() => setOpen(true)}
disableMediaPreview={disableMediaPreview}
/>
</Card.Section>
</Card>
</>
);
}

View File

@@ -9,31 +9,35 @@ import {
Title,
Tooltip,
} from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { useClipboard, useMediaQuery } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import useFetch from 'hooks/useFetch';
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
import {
IconAlarm,
IconCalendarPlus,
IconClipboardCopy,
IconDeviceSdCard,
IconExternalLink,
IconEye,
IconEyeglass,
IconFile,
IconFileDownload,
IconFolderCancel,
IconFolderMinus,
IconFolderPlus,
IconHash,
IconInfoCircle,
IconPhoto,
IconPhotoCancel,
IconPhotoMinus,
IconPhotoStar,
} from '@tabler/icons-react';
import useFetch, { ApiError } from 'hooks/useFetch';
import { useFileDelete, useFileFavorite, UserFilesResponse } from 'lib/queries/files';
import { useFolders } from 'lib/queries/folders';
import { bytesToHuman } from 'lib/utils/bytes';
import { relativeTime } from 'lib/utils/client';
import { useState } from 'react';
import { FileMeta } from '.';
import {
CalendarIcon,
ClockIcon,
CopyIcon,
CrossIcon,
DeleteIcon,
DownloadIcon,
ExternalLinkIcon,
EyeIcon,
FileIcon,
FolderMinusIcon,
FolderPlusIcon,
HashIcon,
ImageIcon,
InfoIcon,
StarIcon,
} from '../icons';
import Type from '../Type';
export default function FileModal({
@@ -44,14 +48,16 @@ export default function FileModal({
refresh,
reducedActions = false,
exifEnabled,
compress,
}: {
open: boolean;
setOpen: (open: boolean) => void;
file: any;
file: UserFilesResponse;
loading: boolean;
refresh: () => void;
reducedActions?: boolean;
exifEnabled?: boolean;
compress: boolean;
}) {
const deleteFile = useFileDelete();
const favoriteFile = useFileFavorite();
@@ -67,16 +73,16 @@ export default function FileModal({
title: 'File Deleted',
message: '',
color: 'green',
icon: <DeleteIcon />,
icon: <IconPhotoMinus size='1rem' />,
});
},
onError: (res: any) => {
onError: (res: ApiError) => {
showNotification({
title: 'Failed to delete file',
message: res.error,
color: 'red',
icon: <CrossIcon />,
icon: <IconPhotoCancel size='1rem' />,
});
},
@@ -99,7 +105,7 @@ export default function FileModal({
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
icon: <IconClipboardCopy size='1rem' />,
});
};
@@ -111,16 +117,16 @@ export default function FileModal({
showNotification({
title: 'The file is now ' + (!file.favorite ? 'favorited' : 'unfavorited'),
message: '',
icon: <StarIcon />,
icon: <IconPhotoStar size='1rem' />,
});
},
onError: (res: any) => {
onError: (res: { error: string }) => {
showNotification({
title: 'Failed to favorite file',
message: res.error,
color: 'red',
icon: <CrossIcon />,
icon: <IconPhotoCancel size='1rem' />,
});
},
}
@@ -141,14 +147,14 @@ export default function FileModal({
title: 'Removed from folder',
message: res.name,
color: 'green',
icon: <FolderMinusIcon />,
icon: <IconFolderMinus size='1rem' />,
});
} else {
showNotification({
title: 'Failed to remove from folder',
message: res.error,
color: 'red',
icon: <CrossIcon />,
icon: <IconFolderCancel size='1rem' />,
});
}
};
@@ -165,14 +171,14 @@ export default function FileModal({
title: 'Added to folder',
message: res.name,
color: 'green',
icon: <FolderPlusIcon />,
icon: <IconFolderPlus size='1rem' />,
});
} else {
showNotification({
title: 'Failed to add to folder',
message: res.error,
color: 'red',
icon: <CrossIcon />,
icon: <IconFolderCancel size='1rem' />,
});
}
};
@@ -189,14 +195,14 @@ export default function FileModal({
title: 'Created & added to folder',
message: res.name,
color: 'green',
icon: <FolderPlusIcon />,
icon: <IconFolderPlus size='1rem' />,
});
} else {
showNotification({
title: 'Failed to create folder',
message: res.error,
color: 'red',
icon: <CrossIcon />,
icon: <IconFolderCancel size='1rem' />,
});
}
});
@@ -204,12 +210,18 @@ export default function FileModal({
};
return (
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>{file.name}</Title>} size='xl'>
<Modal
opened={open}
onClose={() => setOpen(false)}
title={<Title>{file.name}</Title>}
size='auto'
fullScreen={useMediaQuery('(max-width: 600px)')}
>
<LoadingOverlay visible={loading} />
<Stack>
<Type
file={file}
src={`/r/${encodeURI(file.name)}`}
src={`/r/${encodeURI(file.name)}?compress=${compress}`}
alt={file.name}
popup
sx={{ minHeight: 200 }}
@@ -227,32 +239,33 @@ export default function FileModal({
{ maxWidth: 1200, cols: 3 },
]}
>
<FileMeta Icon={FileIcon} title='Name' subtitle={file.name} />
<FileMeta Icon={ImageIcon} title='Type' subtitle={file.mimetype} />
<FileMeta Icon={EyeIcon} title='Views' subtitle={file?.views?.toLocaleString()} />
<FileMeta Icon={IconFile} title='Name' subtitle={file.name} />
<FileMeta Icon={IconPhoto} title='Type' subtitle={file.mimetype} />
<FileMeta Icon={IconDeviceSdCard} title='Size' subtitle={bytesToHuman(file.size || 0)} />
<FileMeta Icon={IconEye} title='Views' subtitle={file?.views?.toLocaleString()} />
{file.maxViews && (
<FileMeta
Icon={EyeIcon}
Icon={IconEyeglass}
title='Max views'
subtitle={file?.maxViews?.toLocaleString()}
tooltip={`This file will be deleted after being viewed ${file?.maxViews?.toLocaleString()} times.`}
/>
)}
<FileMeta
Icon={CalendarIcon}
Icon={IconCalendarPlus}
title='Uploaded'
subtitle={relativeTime(new Date(file.createdAt))}
tooltip={new Date(file?.createdAt).toLocaleString()}
/>
{file.expiresAt && !reducedActions && (
<FileMeta
Icon={ClockIcon}
Icon={IconAlarm}
title='Expires'
subtitle={relativeTime(new Date(file.expiresAt))}
tooltip={new Date(file.expiresAt).toLocaleString()}
/>
)}
<FileMeta Icon={HashIcon} title='ID' subtitle={file.id} />
<FileMeta Icon={IconHash} title='ID' subtitle={file.id} />
</SimpleGrid>
</Stack>
@@ -265,7 +278,7 @@ export default function FileModal({
variant='filled'
onClick={() => window.open(`/dashboard/metadata/${file.id}`, '_blank')}
>
<InfoIcon />
<IconInfoCircle size='1rem' />
</ActionIcon>
</Tooltip>
)}
@@ -274,7 +287,7 @@ export default function FileModal({
label={`Remove from folder "${folders.data.find((f) => f.id === file.folderId)?.name ?? ''}"`}
>
<ActionIcon color='red' variant='filled' onClick={removeFromFolder} loading={folders.isLoading}>
<FolderMinusIcon />
<IconFolderMinus size='1rem' />
</ActionIcon>
</Tooltip>
) : (
@@ -301,7 +314,7 @@ export default function FileModal({
<>
<Tooltip label='Delete file'>
<ActionIcon color='red' variant='filled' onClick={handleDelete}>
<DeleteIcon />
<IconPhotoMinus size='1rem' />
</ActionIcon>
</Tooltip>
@@ -311,7 +324,7 @@ export default function FileModal({
variant='filled'
onClick={handleFavorite}
>
<StarIcon />
<IconPhotoStar size='1rem' />
</ActionIcon>
</Tooltip>
</>
@@ -319,13 +332,13 @@ export default function FileModal({
<Tooltip label='Open in new tab'>
<ActionIcon color='blue' variant='filled' onClick={() => window.open(file.url, '_blank')}>
<ExternalLinkIcon />
<IconExternalLink size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Copy URL'>
<ActionIcon color='blue' variant='filled' onClick={handleCopy}>
<CopyIcon />
<IconClipboardCopy size='1rem' />
</ActionIcon>
</Tooltip>
@@ -335,7 +348,7 @@ export default function FileModal({
variant='filled'
onClick={() => window.open(`/r/${encodeURI(file.name)}?download=true`, '_blank')}
>
<DownloadIcon />
<IconFileDownload size='1rem' />
</ActionIcon>
</Tooltip>
</Group>

View File

@@ -34,6 +34,7 @@ export default function File({
exifEnabled,
refreshImages,
reducedActions = false,
onDash,
}) {
const [open, setOpen] = useState(false);
const deleteFile = useFileDelete();
@@ -57,9 +58,10 @@ export default function File({
refresh={refresh}
reducedActions={reducedActions}
exifEnabled={exifEnabled}
compress={onDash}
/>
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md' onClick={() => setOpen(true)}>
<Card.Section>
<LoadingOverlay visible={loading} />
<Type
@@ -78,9 +80,8 @@ export default function File({
width: '100%',
cursor: 'pointer',
}}
src={`/r/${encodeURI(image.name)}`}
src={`/r/${encodeURI(image.name)}?compress=${onDash}`}
alt={image.name}
onClick={() => setOpen(true)}
disableMediaPreview={disableMediaPreview}
/>
</Card.Section>

View File

@@ -13,6 +13,7 @@ import {
Navbar,
NavLink,
Paper,
rem,
ScrollArea,
Select,
Text,
@@ -23,36 +24,39 @@ import {
import { useClipboard, useMediaQuery } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import {
IconBackspace,
IconBrandDiscordFilled,
IconBrandGithubFilled,
IconBrandGoogle,
IconBrush,
IconClipboardCopy,
IconExternalLink,
IconFiles,
IconFileText,
IconFileUpload,
IconFolders,
IconGraph,
IconHome,
IconLink,
IconLogout,
IconReload,
IconSettings,
IconTag,
IconUpload,
IconUser,
IconUserCog,
IconUsers,
} from '@tabler/icons-react';
import useFetch from 'hooks/useFetch';
import { useVersion } from 'lib/queries/version';
import { userSelector } from 'lib/recoil/user';
import { capitalize } from 'lib/utils/client';
import { UserExtended } from 'middleware/withZipline';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useRecoilState } from 'recoil';
import {
ActivityIcon,
CheckIcon,
CopyIcon,
CrossIcon,
DeleteIcon,
DiscordIcon,
ExternalLinkIcon,
FileIcon,
FolderIcon,
GitHubIcon,
GoogleIcon,
HomeIcon,
LinkIcon,
LogoutIcon,
PencilIcon,
SettingsIcon,
TagIcon,
TypeIcon,
UploadIcon,
UserIcon,
} from './icons';
import { friendlyThemeName, themes } from './Theming';
export type NavbarItems = {
@@ -60,67 +64,67 @@ export type NavbarItems = {
text: string;
link?: string;
children?: NavbarItems[];
if?: (user: any, props: any) => boolean;
if?: (user: UserExtended, props: unknown) => boolean;
};
const items: NavbarItems[] = [
{
icon: <HomeIcon size={18} />,
icon: <IconHome size={18} />,
text: 'Home',
link: '/dashboard',
},
{
icon: <FileIcon size={18} />,
icon: <IconFiles size={18} />,
text: 'Files',
link: '/dashboard/files',
},
{
icon: <FolderIcon size={18} />,
icon: <IconFolders size={18} />,
text: 'Folders',
link: '/dashboard/folders',
},
{
icon: <ActivityIcon size={18} />,
icon: <IconGraph size={18} />,
text: 'Stats',
link: '/dashboard/stats',
},
{
icon: <LinkIcon size={18} />,
icon: <IconLink size={18} />,
text: 'URLs',
link: '/dashboard/urls',
},
{
icon: <UploadIcon size={18} />,
icon: <IconUpload size={18} />,
text: 'Upload',
children: [
{
icon: <UploadIcon size={18} />,
icon: <IconFileUpload size={18} />,
text: 'File',
link: '/dashboard/upload/file',
},
{
icon: <TypeIcon size={18} />,
icon: <IconFileText size={18} />,
text: 'Text',
link: '/dashboard/upload/text',
},
],
},
{
icon: <UserIcon size={18} />,
icon: <IconUser size={18} />,
text: 'Administration',
if: (user, _) => user.administrator as boolean,
children: [
{
icon: <UserIcon size={18} />,
icon: <IconUsers size={18} />,
text: 'Users',
link: '/dashboard/users',
if: () => true,
},
{
icon: <TagIcon size={18} />,
icon: <IconTag size={18} />,
text: 'Invites',
link: '/dashboard/invites',
if: (_, props) => props.invites,
if: (_, props: { invites: boolean }) => props.invites,
},
],
},
@@ -132,9 +136,9 @@ export default function Layout({ children, props }) {
const { title, oauth_providers: unparsed } = props;
const oauth_providers = JSON.parse(unparsed);
const icons = {
GitHub: GitHubIcon,
Discord: DiscordIcon,
Google: GoogleIcon,
GitHub: IconBrandGithubFilled,
Discord: IconBrandDiscordFilled,
Google: IconBrandGoogle,
};
for (const provider of oauth_providers) {
@@ -167,7 +171,7 @@ export default function Layout({ children, props }) {
title: `Theme changed to ${friendlyThemeName[value]}`,
message: '',
color: 'green',
icon: <PencilIcon />,
icon: <IconBrush size='1rem' />,
});
};
@@ -188,7 +192,7 @@ export default function Layout({ children, props }) {
title: 'Token Reset Failed',
message: a.error,
color: 'red',
icon: <CrossIcon />,
icon: <IconReload size='1rem' />,
});
} else {
showNotification({
@@ -196,7 +200,7 @@ export default function Layout({ children, props }) {
message:
'Your token has been reset. You will need to update any uploaders to use this new token.',
color: 'green',
icon: <CheckIcon />,
icon: <IconReload size='1rem' />,
});
}
@@ -237,7 +241,7 @@ export default function Layout({ children, props }) {
title: 'Token Copied',
message: 'Your token has been copied to your clipboard.',
color: 'green',
icon: <CheckIcon />,
icon: <IconClipboardCopy size='1rem' />,
});
modals.closeAll();
@@ -264,42 +268,42 @@ export default function Layout({ children, props }) {
{children
.filter((x) => (x.if ? x.if(user, props) : true))
.map(({ icon, text, link }) => (
<Link href={link} key={text} passHref legacyBehavior>
<NavLink
component='a'
label={text}
icon={icon}
active={router.pathname === link}
variant='light'
/>
</Link>
<NavLink
key={text}
label={text}
icon={icon}
active={router.pathname === link}
variant='light'
component={Link}
href={link}
/>
))}
</NavLink>
) : (
<Link href={link} key={text} passHref legacyBehavior>
<NavLink
component='a'
label={text}
icon={icon}
active={router.pathname === link}
variant='light'
/>
</Link>
<NavLink
key={text}
label={text}
icon={icon}
active={router.pathname === link}
variant='light'
component={Link}
href={link}
/>
)
)}
</Navbar.Section>
<Navbar.Section>
{external_links.length
? external_links.map(({ label, link }, i) => (
<Link href={link} passHref key={i} legacyBehavior>
<NavLink
label={label}
component='a'
target='_blank'
variant='light'
icon={<ExternalLinkIcon />}
/>
</Link>
? external_links.map(({ label, link }, i: number) => (
<NavLink
key={i}
label={label}
target='_blank'
variant='light'
icon={<IconExternalLink size={18} />}
component={Link}
href={link}
/>
))
: null}
</Navbar.Section>
@@ -353,14 +357,12 @@ export default function Layout({ children, props }) {
>
<Menu.Target>
<Button
leftIcon={avatar ? <Image src={avatar} height={32} radius='md' /> : <SettingsIcon />}
sx={(t) => ({
backgroundColor: 'inherit',
'&:hover': {
backgroundColor: t.other.hover,
},
color: t.colorScheme === 'dark' ? 'white' : 'black',
})}
leftIcon={
avatar ? <Image src={avatar} height={32} radius='md' /> : <IconUserCog size='1rem' />
}
variant='subtle'
color='gray'
compact
size='xl'
p='sm'
>
@@ -372,19 +374,39 @@ export default function Layout({ children, props }) {
{user.username} ({user.id}){' '}
{user.administrator && user.username !== 'administrator' ? '(Administrator)' : ''}
</Menu.Label>
<Menu.Item component={Link} icon={<SettingsIcon />} href='/dashboard/manage'>
<Menu.Item component={Link} icon={<IconFiles size='1rem' />} href='/dashboard/files'>
Files
</Menu.Item>
<Menu.Item
component={Link}
icon={<IconFileUpload size='1rem' />}
href='/dashboard/upload/file'
>
Upload File
</Menu.Item>
<Menu.Item component={Link} icon={<IconLink size='1rem' />} href='/dashboard/urls'>
Shorten URL
</Menu.Item>
<Menu.Label>Settings</Menu.Label>
<Menu.Item component={Link} icon={<IconSettings size='1rem' />} href='/dashboard/manage'>
Manage Account
</Menu.Item>
<Menu.Item
icon={<CopyIcon />}
icon={<IconClipboardCopy size='1rem' />}
onClick={() => {
openCopyToken();
}}
>
Copy Token
</Menu.Item>
<Menu.Item icon={<IconLogout size='1rem' />} component={Link} href='/auth/logout'>
Logout
</Menu.Item>
<Menu.Label>Danger</Menu.Label>
<Menu.Item
icon={<DeleteIcon />}
icon={<IconBackspace size='1rem' />}
onClick={() => {
openResetToken();
}}
@@ -392,11 +414,13 @@ export default function Layout({ children, props }) {
>
Reset Token
</Menu.Item>
<Menu.Item component={Link} icon={<LogoutIcon />} href='/auth/logout' color='red'>
Logout
</Menu.Item>
<Menu.Divider />
<>
{oauth_providers.filter((x) =>
user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase())
).length ? (
<Menu.Label>Connected Accounts</Menu.Label>
) : null}
{oauth_providers
.filter((x) =>
user.oauth
@@ -420,7 +444,7 @@ export default function Layout({ children, props }) {
<Menu.Divider />
) : null}
</>
<Menu.Item closeMenuOnClick={false} icon={<PencilIcon />}>
<Menu.Item closeMenuOnClick={false} icon={<IconBrush size='1rem' />}>
<Select
size={useMediaQuery('(max-width: 768px)') ? 'md' : 'xs'}
data={Object.keys(themes).map((t) => ({
@@ -441,9 +465,15 @@ export default function Layout({ children, props }) {
<Paper
withBorder
p='md'
mr='md'
mb='md'
shadow='xs'
sx={(t) => ({
borderColor: t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.dark[0],
sx={(theme) => ({
'&[data-with-border]': {
border: `${rem(1)} solid ${
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[0]
}`,
},
})}
>
{children}

View File

@@ -1,5 +0,0 @@
import { NextLink } from '@mantine/next';
export default function Link(props) {
return <NextLink legacyBehavior {...props} />;
}

View File

@@ -1,13 +1,13 @@
// https://mantine.dev/core/password-input/
import { Box, PasswordInput, Popover, Progress, Text } from '@mantine/core';
import { IconCheck, IconCross } from '@tabler/icons-react';
import { useState } from 'react';
import { CheckIcon, CrossIcon } from './icons';
function PasswordRequirement({ meets, label }: { meets: boolean; label: string }) {
return (
<Text color={meets ? 'teal' : 'red'} sx={{ display: 'flex', alignItems: 'center' }} mt='sm' size='sm'>
{meets ? <CheckIcon /> : <CrossIcon />} <Box ml='md'>{label}</Box>
{meets ? <IconCheck size='1rem' /> : <IconCross size='1rem' />} <Box ml='md'>{label}</Box>
</Text>
);
}

View File

@@ -1,9 +1,9 @@
import { Card, createStyles, Group, Text } from '@mantine/core';
import { ArrowDownRight, ArrowUpRight } from 'react-feather';
import { IconArrowDownRight, IconArrowUpRight } from '@tabler/icons-react';
const useStyles = createStyles((theme) => ({
root: {
padding: theme.spacing.xl * 1.5,
padding: `calc(${theme.spacing.xl} * 1.5)`,
},
value: {
@@ -57,7 +57,7 @@ export default function StatCard({ stat }: StatsGridProps) {
<>
<Text color={stat.diff >= 0 ? 'teal' : 'red'} size='sm' weight={500} className={classes.diff}>
<span>{stat.diff === Infinity ? '∞' : stat.diff}%</span>
{stat.diff >= 0 ? <ArrowUpRight size={16} /> : <ArrowDownRight size={16} />}
{stat.diff >= 0 ? <IconArrowUpRight size={16} /> : <IconArrowDownRight size={16} />}
</Text>
</>
)}

View File

@@ -15,10 +15,15 @@ import qogir_dark from 'lib/themes/qogir_dark';
import { createEmotionCache, MantineProvider, MantineThemeOverride } from '@mantine/core';
import { useColorScheme } from '@mantine/hooks';
import { ModalsProvider } from '@mantine/modals';
import { NotificationsProvider } from '@mantine/notifications';
import { Notifications } from '@mantine/notifications';
import { SpotlightProvider } from '@mantine/spotlight';
import { userSelector } from 'lib/recoil/user';
import { useRecoilValue } from 'recoil';
import { createSpotlightActions } from 'lib/spotlight';
import { useRouter } from 'next/router';
import { IconSearch } from '@tabler/icons-react';
export const themes = {
system: (colorScheme: 'dark' | 'light') => (colorScheme === 'dark' ? dark_blue : light_blue),
dark_blue,
@@ -52,6 +57,7 @@ const cache = createEmotionCache({ key: 'zipline' });
export default function ZiplineTheming({ Component, pageProps, ...props }) {
const user = useRecoilValue(userSelector);
const colorScheme = useColorScheme();
const router = useRouter();
let theme: MantineThemeOverride;
@@ -78,7 +84,7 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
components: {
AppShell: {
styles: (t) => ({
root: {
main: {
backgroundColor: t.other.AppShell_backgroundColor,
},
}),
@@ -92,10 +98,15 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
},
Modal: {
defaultProps: {
closeButtonProps: { size: 'lg' },
centered: true,
overlayBlur: 3,
overlayColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
exitTransitionDuration: 100,
transitionProps: {
exitDuration: 100,
},
overlayProps: {
blur: 6,
color: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
},
},
},
Popover: {
@@ -106,8 +117,8 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
},
LoadingOverlay: {
defaultProps: {
overlayBlur: 3,
overlayColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
overlayOpacity: 0.3,
},
},
Loader: {
@@ -133,9 +144,14 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
}}
>
<ModalsProvider>
<NotificationsProvider position='top-center' style={{ marginTop: -10 }}>
<SpotlightProvider
searchIcon={<IconSearch size='1rem' />}
shortcut={['mod + k', '/']}
actions={createSpotlightActions(router)}
>
<Notifications position='top-center' style={{ marginTop: -10 }} />
{props.children ? props.children : <Component {...pageProps} />}
</NotificationsProvider>
</SpotlightProvider>
</ModalsProvider>
</MantineProvider>
);

View File

@@ -1,4 +1,3 @@
import exts from 'lib/exts';
import {
Alert,
Box,
@@ -11,8 +10,17 @@ import {
Text,
UnstyledButton,
} from '@mantine/core';
import {
IconFile,
IconFileAlert,
IconFileText,
IconFileUnknown,
IconHeadphones,
IconPhotoCancel,
IconPlayerPlay,
} from '@tabler/icons-react';
import exts from 'lib/exts';
import { useEffect, useState } from 'react';
import { AudioIcon, FileIcon, ImageIcon, PlayIcon } from './icons';
import KaTeX from './render/KaTeX';
import Markdown from './render/Markdown';
import PrismCode from './render/PrismCode';
@@ -37,8 +45,8 @@ function Placeholder({ text, Icon, ...props }) {
);
return (
<Box sx={{ height: 200 }} {...props}>
<Center sx={{ height: 200 }}>
<Box sx={{ height: 320 }} {...props}>
<Center sx={{ height: 320 }}>
<PlaceholderContent text={text} Icon={Icon} />
</Center>
</Box>
@@ -103,13 +111,13 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
);
if (media && disableMediaPreview) {
return <Placeholder Icon={FileIcon} text={`Click to view file (${file.name})`} {...props} />;
return <Placeholder Icon={IconFile} text={`Click to view file (${file.name})`} {...props} />;
}
if (file.password) {
return (
<Placeholder
Icon={FileIcon}
Icon={IconFileAlert}
text={`This file is password protected. Click to view file (${file.name})`}
onClick={() => window.open(file.url)}
{...props}
@@ -123,7 +131,12 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
video: <video width='100%' autoPlay muted controls {...props} />,
image: (
<Image
placeholder={<PlaceholderContent Icon={FileIcon} text={'Image failed to load...'} />}
styles={{
imageWrapper: {
position: 'inherit',
},
}}
placeholder={<PlaceholderContent Icon={IconPhotoCancel} text={'Image failed to load...'} />}
{...props}
/>
),
@@ -146,17 +159,19 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
)
) : media ? (
{
video: <Placeholder Icon={PlayIcon} text={`Click to view video (${file.name})`} {...props} />,
video: <Placeholder Icon={IconPlayerPlay} text={`Click to view video (${file.name})`} {...props} />,
image: (
<Image
placeholder={<PlaceholderContent Icon={ImageIcon} text={'Image failed to load...'} />}
placeholder={<PlaceholderContent Icon={IconPhotoCancel} text={'Image failed to load...'} />}
height={320}
fit='contain'
{...props}
/>
),
audio: <Placeholder Icon={AudioIcon} text={`Click to view audio (${file.name})`} {...props} />,
text: <Placeholder Icon={FileIcon} text={`Click to view text file (${file.name})`} {...props} />,
audio: <Placeholder Icon={IconHeadphones} text={`Click to view audio (${file.name})`} {...props} />,
text: <Placeholder Icon={IconFileText} text={`Click to view text file (${file.name})`} {...props} />,
}[type]
) : (
<Placeholder Icon={FileIcon} text={`Click to view file (${file.name})`} {...props} />
<Placeholder Icon={IconFileUnknown} text={`Click to view file (${file.name})`} {...props} />
);
}

View File

@@ -1,10 +1,8 @@
import { Box, Group, SimpleGrid, Text, useMantineTheme } from '@mantine/core';
import { Box, Group, SimpleGrid, Text } from '@mantine/core';
import { Dropzone as MantineDropzone } from '@mantine/dropzone';
import { ImageIcon } from 'components/icons';
import { IconPhoto } from '@tabler/icons-react';
export default function Dropzone({ loading, onDrop, children }) {
const theme = useMantineTheme();
return (
<SimpleGrid
cols={2}
@@ -13,9 +11,9 @@ export default function Dropzone({ loading, onDrop, children }) {
{ maxWidth: 'xs', cols: 1 },
]}
>
<MantineDropzone onDrop={onDrop} styles={{ inner: { pointerEvents: 'none' } }}>
<MantineDropzone loading={loading} onDrop={onDrop} styles={{ inner: { pointerEvents: 'none' } }}>
<Group position='center' spacing='xl' style={{ minHeight: 440 }}>
<ImageIcon size={80} />
<IconPhoto size={80} />
<Text size='xl' inline>
Drag files here or click to select files

View File

@@ -1,6 +1,6 @@
import { ActionIcon, Badge, Box, Card, Group, HoverCard, Table, useMantineTheme } from '@mantine/core';
import { ActionIcon, Box, Card, Group, HoverCard, Table, useMantineTheme } from '@mantine/core';
import { IconX } from '@tabler/icons-react';
import Type from 'components/Type';
import { X } from 'react-feather';
export function FilePreview({ file }: { file: File }) {
return (
@@ -23,7 +23,6 @@ export default function FileDropzone({ file, onRemove }: { file: File; onRemove:
return (
<HoverCard shadow='md'>
<HoverCard.Target>
{/* <Badge size='lg'>{file.name}</Badge> */}
<Card shadow='sm' radius='sm' p='sm'>
<Group position='center' spacing='xl'>
{file.name}
@@ -31,7 +30,6 @@ export default function FileDropzone({ file, onRemove }: { file: File; onRemove:
</Card>
</HoverCard.Target>
<HoverCard.Dropdown>
{/* x button that will remove file */}
<Box
sx={{
position: 'absolute',
@@ -43,7 +41,7 @@ export default function FileDropzone({ file, onRemove }: { file: File; onRemove:
m='xs'
>
<ActionIcon onClick={onRemove} size='sm' color='red' variant='filled'>
<X />
<IconX />
</ActionIcon>
</Box>

View File

@@ -1,5 +0,0 @@
import { Activity } from 'react-feather';
export default function ActivityIcon({ ...props }) {
return <Activity size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Disc } from 'react-feather';
export default function AudioIcon({ ...props }) {
return <Disc size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Calendar } from 'react-feather';
export default function CalendarIcon({ ...props }) {
return <Calendar size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Check } from 'react-feather';
export default function CheckIcon({ ...props }) {
return <Check size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Clock } from 'react-feather';
export default function ClockIcon({ ...props }) {
return <Clock size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Copy } from 'react-feather';
export default function CopyIcon({ ...props }) {
return <Copy size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { X } from 'react-feather';
export default function CrossIcon({ ...props }) {
return <X size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Database } from 'react-feather';
export default function DatabaseIcon({ ...props }) {
return <Database size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Delete } from 'react-feather';
export default function DeleteIcon({ ...props }) {
return <Delete size={15} {...props} />;
}

View File

@@ -1,19 +0,0 @@
// https://discord.com/branding
export default function DiscordIcon({ ...props }) {
return (
<svg width='24' height='24' viewBox='0 0 71 55' xmlns='http://www.w3.org/2000/svg'>
<g clipPath='url(#clip0)'>
<path
fill={props.colorScheme === 'manage' ? '#ffffff' : '#5865F2'}
d='M60.1045 4.8978C55.5792 2.8214 50.7265 1.2916 45.6527 0.41542C45.5603 0.39851 45.468 0.440769 45.4204 0.525289C44.7963 1.6353 44.105 3.0834 43.6209 4.2216C38.1637 3.4046 32.7345 3.4046 27.3892 4.2216C26.905 3.0581 26.1886 1.6353 25.5617 0.525289C25.5141 0.443589 25.4218 0.40133 25.3294 0.41542C20.2584 1.2888 15.4057 2.8186 10.8776 4.8978C10.8384 4.9147 10.8048 4.9429 10.7825 4.9795C1.57795 18.7309 -0.943561 32.1443 0.293408 45.3914C0.299005 45.4562 0.335386 45.5182 0.385761 45.5576C6.45866 50.0174 12.3413 52.7249 18.1147 54.5195C18.2071 54.5477 18.305 54.5139 18.3638 54.4378C19.7295 52.5728 20.9469 50.6063 21.9907 48.5383C22.0523 48.4172 21.9935 48.2735 21.8676 48.2256C19.9366 47.4931 18.0979 46.6 16.3292 45.5858C16.1893 45.5041 16.1781 45.304 16.3068 45.2082C16.679 44.9293 17.0513 44.6391 17.4067 44.3461C17.471 44.2926 17.5606 44.2813 17.6362 44.3151C29.2558 49.6202 41.8354 49.6202 53.3179 44.3151C53.3935 44.2785 53.4831 44.2898 53.5502 44.3433C53.9057 44.6363 54.2779 44.9293 54.6529 45.2082C54.7816 45.304 54.7732 45.5041 54.6333 45.5858C52.8646 46.6197 51.0259 47.4931 49.0921 48.2228C48.9662 48.2707 48.9102 48.4172 48.9718 48.5383C50.038 50.6034 51.2554 52.5699 52.5959 54.435C52.6519 54.5139 52.7526 54.5477 52.845 54.5195C58.6464 52.7249 64.529 50.0174 70.6019 45.5576C70.6551 45.5182 70.6887 45.459 70.6943 45.3942C72.1747 30.0791 68.2147 16.7757 60.1968 4.9823C60.1772 4.9429 60.1437 4.9147 60.1045 4.8978ZM23.7259 37.3253C20.2276 37.3253 17.3451 34.1136 17.3451 30.1693C17.3451 26.225 20.1717 23.0133 23.7259 23.0133C27.308 23.0133 30.1626 26.2532 30.1066 30.1693C30.1066 34.1136 27.28 37.3253 23.7259 37.3253ZM47.3178 37.3253C43.8196 37.3253 40.9371 34.1136 40.9371 30.1693C40.9371 26.225 43.7636 23.0133 47.3178 23.0133C50.9 23.0133 53.7545 26.2532 53.6986 30.1693C53.6986 34.1136 50.9 37.3253 47.3178 37.3253Z'
/>
</g>
<defs>
<clipPath id='clip0'>
<rect width='71' height='55' fill='white' />
</clipPath>
</defs>
</svg>
);
}

View File

@@ -1,5 +0,0 @@
import { Download } from 'react-feather';
export default function DownloadIcon({ ...props }) {
return <Download size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { LogIn } from 'react-feather';
export default function EnterIcon({ ...props }) {
return <LogIn size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { ExternalLink } from 'react-feather';
export default function ExternalLinkIcon({ ...props }) {
return <ExternalLink size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Eye } from 'react-feather';
export default function EyeIcon({ ...props }) {
return <Eye size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { File } from 'react-feather';
export default function FileIcon({ ...props }) {
return <File size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Folder } from 'react-feather';
export default function FolderIcon({ ...props }) {
return <Folder size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { FolderMinus } from 'react-feather';
export default function FolderMinusIcon({ ...props }) {
return <FolderMinus size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { FolderPlus } from 'react-feather';
export default function FolderPlusIcon({ ...props }) {
return <FolderPlus size={15} {...props} />;
}

View File

@@ -1,17 +0,0 @@
import { GitHub } from 'react-feather';
import Image from 'next/image';
// https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg
export default function GitHubIcon({ colorScheme, ...props }) {
return (
<svg width={24} height={24} viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg' {...props}>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z'
transform='scale(64)'
fill={colorScheme === 'dark' ? '#FFFFFF' : '#1B1F23'}
/>
</svg>
);
}

View File

@@ -1,5 +0,0 @@
import { Globe } from 'react-feather';
export default function GlobeIcon({ ...props }) {
return <Globe size={15} {...props} />;
}

View File

@@ -1,15 +0,0 @@
// https://developers.google.com/identity/branding-guidelines
import Image from 'next/image';
export default function GoogleIcon({ colorScheme, ...props }) {
return (
<Image
alt='google'
src='https://madeby.google.com/static/images/google_g_logo.svg'
width={24}
height={24}
{...props}
/>
);
}

View File

@@ -1,5 +0,0 @@
import { HardDrive } from 'react-feather';
export default function HardDriveIcon({ ...props }) {
return <HardDrive size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Hash } from 'react-feather';
export default function HashIcon({ ...props }) {
return <Hash size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Home } from 'react-feather';
export default function HomeIcon({ ...props }) {
return <Home size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Image as FeatherImage } from 'react-feather';
export default function ImageIcon({ ...props }) {
return <FeatherImage size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Info } from 'react-feather';
export default function InfoIcon({ ...props }) {
return <Info size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Key } from 'react-feather';
export default function KeyIcon({ ...props }) {
return <Key size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Link } from 'react-feather';
export default function LinkIcon({ ...props }) {
return <Link size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Lock } from 'react-feather';
export default function LockIcon({ ...props }) {
return <Lock size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { LogOut } from 'react-feather';
export default function LogoutIcon({ ...props }) {
return <LogOut size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Edit2 } from 'react-feather';
export default function PencilIcon({ ...props }) {
return <Edit2 size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Play } from 'react-feather';
export default function PlayIcon({ ...props }) {
return <Play size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Plus } from 'react-feather';
export default function PlusIcon({ ...props }) {
return <Plus size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { RefreshCw } from 'react-feather';
export default function RefreshIcon({ ...props }) {
return <RefreshCw size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Settings } from 'react-feather';
export default function SettingsIcon({ ...props }) {
return <Settings size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Star } from 'react-feather';
export default function StarIcon({ ...props }) {
return <Star size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Tag } from 'react-feather';
export default function TagIcon({ ...props }) {
return <Tag size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Trash2 } from 'react-feather';
export default function TrashIcon({ ...props }) {
return <Trash2 size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Type } from 'react-feather';
export default function TypeIcon({ ...props }) {
return <Type size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Unlock } from 'react-feather';
export default function UnlockIcon({ ...props }) {
return <Unlock size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Upload } from 'react-feather';
export default function UploadIcon({ ...props }) {
return <Upload size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { User } from 'react-feather';
export default function UserIcon({ ...props }) {
return <User size={15} {...props} />;
}

View File

@@ -1,5 +0,0 @@
import { Video } from 'react-feather';
export default function VideoIcon({ ...props }) {
return <Video size={15} {...props} />;
}

View File

@@ -1,91 +1,4 @@
import ActivityIcon from './ActivityIcon';
import CheckIcon from './CheckIcon';
import CopyIcon from './CopyIcon';
import CrossIcon from './CrossIcon';
import DeleteIcon from './DeleteIcon';
import FileIcon from './FileIcon';
import HomeIcon from './HomeIcon';
import LinkIcon from './LinkIcon';
import LogoutIcon from './LogoutIcon';
import PencilIcon from './PencilIcon';
import SettingsIcon from './SettingsIcon';
import TypeIcon from './TypeIcon';
import UploadIcon from './UploadIcon';
import UserIcon from './UserIcon';
import EnterIcon from './EnterIcon';
import PlusIcon from './PlusIcon';
import ImageIcon from './ImageIcon';
import StarIcon from './StarIcon';
import AudioIcon from './AudioIcon';
import VideoIcon from './VideoIcon';
import PlayIcon from './PlayIcon';
import CalendarIcon from './CalendarIcon';
import HashIcon from './HashIcon';
import TagIcon from './TagIcon';
import ClockIcon from './ClockIcon';
import ExternalLinkIcon from './ExternalLinkIcon';
import ShareXIcon from './ShareXIcon';
import DownloadIcon from './DownloadIcon';
import FlameshotIcon from './FlameshotIcon';
import GitHubIcon from './GitHubIcon';
import DiscordIcon from './DiscordIcon';
import GoogleIcon from './GoogleIcon';
import EyeIcon from './EyeIcon';
import RefreshIcon from './RefreshIcon';
import KeyIcon from './KeyIcon';
import DatabaseIcon from './DatabaseIcon';
import InfoIcon from './InfoIcon';
import FolderIcon from './FolderIcon';
import FolderMinusIcon from './FolderMinusIcon';
import FolderPlusIcon from './FolderPlusIcon';
import GlobeIcon from './GlobeIcon';
import LockIcon from './LockIcon';
import UnlockIcon from './UnlockIcon';
import HardDriveIcon from './HardDriveIcon';
export {
ActivityIcon,
CheckIcon,
CopyIcon,
CrossIcon,
DeleteIcon,
FileIcon,
HomeIcon,
LinkIcon,
LogoutIcon,
PencilIcon,
SettingsIcon,
TypeIcon,
UploadIcon,
UserIcon,
EnterIcon,
PlusIcon,
ImageIcon,
StarIcon,
AudioIcon,
VideoIcon,
PlayIcon,
CalendarIcon,
HashIcon,
TagIcon,
ClockIcon,
ExternalLinkIcon,
ShareXIcon,
DownloadIcon,
FlameshotIcon,
GitHubIcon,
DiscordIcon,
GoogleIcon,
EyeIcon,
RefreshIcon,
KeyIcon,
DatabaseIcon,
InfoIcon,
FolderIcon,
FolderMinusIcon,
FolderPlusIcon,
GlobeIcon,
LockIcon,
UnlockIcon,
HardDriveIcon,
};
export { ShareXIcon, FlameshotIcon };

View File

@@ -1,11 +1,11 @@
import { Card as MantineCard, Center, Group, SimpleGrid, Skeleton, Title } from '@mantine/core';
import { randomId } from '@mantine/hooks';
import { IconCloudUpload } from '@tabler/icons-react';
import File from 'components/File';
import MutedText from 'components/MutedText';
import { useRecent } from 'lib/queries/files';
import { UploadCloud } from 'react-feather';
export default function RecentFiles({ disableMediaPreview, exifEnabled }) {
export default function RecentFiles({ disableMediaPreview, exifEnabled, compress }) {
const recent = useRecent('media');
return (
@@ -25,6 +25,7 @@ export default function RecentFiles({ disableMediaPreview, exifEnabled }) {
disableMediaPreview={disableMediaPreview}
exifEnabled={exifEnabled}
refreshImages={recent.refetch}
onDash={compress}
/>
))
) : (
@@ -32,7 +33,7 @@ export default function RecentFiles({ disableMediaPreview, exifEnabled }) {
<Center>
<Group>
<div>
<UploadCloud size={48} />
<IconCloudUpload size={48} />
</div>
<div>
<Title>Nothing here</Title>

View File

@@ -1,8 +1,8 @@
import { SimpleGrid } from '@mantine/core';
import { IconDatabase, IconEye, IconFile, IconUsers } from '@tabler/icons-react';
import StatCard from 'components/StatCard';
import { useStats } from 'lib/queries/stats';
import { percentChange } from 'lib/utils/client';
import { EyeIcon, DatabaseIcon, UserIcon, FileIcon } from 'components/icons';
export function StatCards() {
const stats = useStats();
@@ -23,7 +23,7 @@ export function StatCards() {
title: 'FILES',
value: stats.isSuccess ? latest.data.count.toLocaleString() : '...',
desc: 'files have been uploaded',
icon: <FileIcon />,
icon: <IconFile />,
diff:
stats.isSuccess && before?.data ? percentChange(before.data.count, latest.data.count) : undefined,
}}
@@ -34,7 +34,7 @@ export function StatCards() {
title: 'STORAGE',
value: stats.isSuccess ? latest.data.size : '...',
desc: 'used',
icon: <DatabaseIcon />,
icon: <IconDatabase />,
diff:
stats.isSuccess && before?.data
? percentChange(before.data.size_num, latest.data.size_num)
@@ -47,7 +47,7 @@ export function StatCards() {
title: 'VIEWS',
value: stats.isSuccess ? latest.data.views_count.toLocaleString() : '...',
desc: 'total file views',
icon: <EyeIcon />,
icon: <IconEye />,
diff:
stats.isSuccess && before?.data
? percentChange(before.data.views_count, latest.data.views_count)
@@ -60,7 +60,7 @@ export function StatCards() {
title: 'USERS',
value: stats.isSuccess ? latest.data.count_users.toLocaleString() : '...',
desc: 'users',
icon: <UserIcon />,
icon: <IconUsers />,
}}
/>
</SimpleGrid>

View File

@@ -1,21 +1,29 @@
import { ActionIcon, Box, Group, Title, Tooltip } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import {
IconClipboardCopy,
IconExternalLink,
IconGridDots,
IconPhotoCancel,
IconPhotoMinus,
IconPhotoUp,
} from '@tabler/icons-react';
import FileModal from 'components/File/FileModal';
import { CopyIcon, CrossIcon, DeleteIcon, EnterIcon, FileIcon } from 'components/icons';
import Link from 'components/Link';
import MutedText from 'components/MutedText';
import useFetch from 'lib/hooks/useFetch';
import { usePaginatedFiles, useRecent } from 'lib/queries/files';
import { useStats } from 'lib/queries/stats';
import { userSelector } from 'lib/recoil/user';
import { bytesToHuman } from 'lib/utils/bytes';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import RecentFiles from './RecentFiles';
import { StatCards } from './StatCards';
export default function Dashboard({ disableMediaPreview, exifEnabled }) {
export default function Dashboard({ disableMediaPreview, exifEnabled, compress }) {
const user = useRecoilValue(userSelector);
const recent = useRecent('media');
@@ -37,7 +45,7 @@ export default function Dashboard({ disableMediaPreview, exifEnabled }) {
})();
}, [page]);
const files = usePaginatedFiles(page);
const files = usePaginatedFiles(page, 'none');
// sorting
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
@@ -84,14 +92,14 @@ export default function Dashboard({ disableMediaPreview, exifEnabled }) {
title: 'File Deleted',
message: `${file.name}`,
color: 'green',
icon: <DeleteIcon />,
icon: <IconPhotoMinus size='1rem' />,
});
} else {
showNotification({
title: 'Failed to Delete File',
message: res.error,
color: 'red',
icon: <CrossIcon />,
icon: <IconPhotoCancel size='1rem' />,
});
}
};
@@ -112,7 +120,7 @@ export default function Dashboard({ disableMediaPreview, exifEnabled }) {
href={`${window.location.protocol}//${window.location.host}${file.url}`}
>{`${window.location.protocol}//${window.location.host}${file.url}`}</a>
),
icon: <CopyIcon />,
icon: <IconClipboardCopy size='1rem' />,
});
};
@@ -131,6 +139,7 @@ export default function Dashboard({ disableMediaPreview, exifEnabled }) {
refresh={() => files.refetch()}
reducedActions={false}
exifEnabled={exifEnabled}
compress={compress}
/>
)}
@@ -141,13 +150,17 @@ export default function Dashboard({ disableMediaPreview, exifEnabled }) {
<StatCards />
<RecentFiles disableMediaPreview={disableMediaPreview} exifEnabled={exifEnabled} />
<RecentFiles disableMediaPreview={disableMediaPreview} exifEnabled={exifEnabled} compress={compress} />
<Box my='sm'>
<Title>Files</Title>
<MutedText size='md'>
View your gallery <Link href='/dashboard/files'>here</Link>.
</MutedText>
<Group mb='md'>
<Title>Files</Title>
<Tooltip label='View Gallery'>
<ActionIcon variant='filled' color='primary' component={Link} href='/dashboard/files'>
<IconGridDots size='1rem' />
</ActionIcon>
</Tooltip>
</Group>
<DataTable
withBorder
@@ -157,6 +170,7 @@ export default function Dashboard({ disableMediaPreview, exifEnabled }) {
columns={[
{ accessor: 'name', sortable: true },
{ accessor: 'mimetype', sortable: true },
{ accessor: 'size', sortable: true, render: (file) => bytesToHuman(file.size) },
{
accessor: 'createdAt',
sortable: true,
@@ -175,21 +189,21 @@ export default function Dashboard({ disableMediaPreview, exifEnabled }) {
}}
color='blue'
>
<FileIcon />
<IconPhotoUp size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Open file in new tab'>
<ActionIcon onClick={() => viewFile(file)} color='blue'>
<EnterIcon />
<IconExternalLink size='1rem' />
</ActionIcon>
</Tooltip>
<ActionIcon onClick={() => copyFile(file)} color='green'>
<CopyIcon />
<IconClipboardCopy size='1rem' />
</ActionIcon>
<ActionIcon onClick={() => deleteFile(file)} color='red'>
<DeleteIcon />
<IconPhotoMinus size='1rem' />
</ActionIcon>
</Group>
),
@@ -212,25 +226,27 @@ export default function Dashboard({ disableMediaPreview, exifEnabled }) {
items: (file) => [
{
key: 'view',
icon: <EnterIcon />,
icon: <IconExternalLink size='1rem' />,
title: `View ${file.name}`,
onClick: () => viewFile(file),
},
{
key: 'copy',
icon: <CopyIcon />,
icon: <IconClipboardCopy size='1rem' />,
title: `Copy ${file.name}`,
onClick: () => copyFile(file),
},
{
key: 'delete',
icon: <DeleteIcon />,
icon: <IconPhotoMinus size='1rem' />,
title: `Delete ${file.name}`,
onClick: () => deleteFile(file),
},
],
}}
onCellClick={({ record: file }) => {
onCellClick={({ column, record: file }) => {
if (column.accessor === 'actions') return;
setSelectedFile(file);
setOpen(true);
}}

View File

@@ -1,7 +1,7 @@
import { Box, Button, Center, Checkbox, Group, Pagination, SimpleGrid, Skeleton, Title } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import { IconFile } from '@tabler/icons-react';
import File from 'components/File';
import { FileIcon } from 'components/icons';
import MutedText from 'components/MutedText';
import useFetch from 'hooks/useFetch';
import { usePaginatedFiles } from 'lib/queries/files';
@@ -10,7 +10,7 @@ import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
export default function FilePagation({ disableMediaPreview, exifEnabled, queryPage }) {
export default function FilePagation({ disableMediaPreview, exifEnabled, queryPage, compress }) {
const [checked, setChecked] = useRecoilState(showNonMediaSelector);
const [numPages, setNumPages] = useState(Number(queryPage)); // just set it to the queryPage, since the req may have not loaded yet
const [page, setPage] = useState(Number(queryPage));
@@ -44,7 +44,7 @@ export default function FilePagation({ disableMediaPreview, exifEnabled, queryPa
<Center sx={{ flexDirection: 'column' }}>
<Group>
<div>
<FileIcon size={48} />
<IconFile size={48} />
</div>
<div>
<Title>Nothing here</Title>
@@ -75,6 +75,7 @@ export default function FilePagation({ disableMediaPreview, exifEnabled, queryPa
disableMediaPreview={disableMediaPreview}
exifEnabled={exifEnabled}
refreshImages={pages.refetch}
onDash={compress}
/>
</div>
))
@@ -96,7 +97,7 @@ export default function FilePagation({ disableMediaPreview, exifEnabled, queryPa
}}
>
{!isMobile && <div></div>}
<Pagination total={numPages} page={page} onChange={setPage} withEdges />
<Pagination total={numPages} value={page} onChange={setPage} withEdges />
{!isMobile && (
<Checkbox
label='Show non-media files'

View File

@@ -0,0 +1,118 @@
import { Button, Modal, Title, Tooltip } from '@mantine/core';
import { IconTrash } from '@tabler/icons-react';
import AnchorNext from 'components/AnchorNext';
import MutedText from 'components/MutedText';
import useFetch from 'hooks/useFetch';
import { DataTable } from 'mantine-datatable';
import { useEffect, useState } from 'react';
export type PendingFiles = {
id: number;
createdAt: string;
status: string;
chunks: number;
chunksComplete: number;
userId: number;
data: {
file: {
filename: string;
mimetype: string;
lastchunk: boolean;
identifier: string;
totalBytes: number;
};
code?: number;
message?: string;
};
};
export default function PendingFilesModal({ open, onClose }) {
const [incFiles, setIncFiles] = useState<PendingFiles[]>([]);
const [loading, setLoading] = useState(true);
const [selectedFiles, setSelectedFiles] = useState<PendingFiles[]>([]);
async function updateIncFiles() {
setLoading(true);
const files = await useFetch('/api/user/pending');
setIncFiles(files);
setLoading(false);
}
async function deleteIncFiles() {
await useFetch('/api/user/pending', 'DELETE', {
id: selectedFiles.map((file) => file.id),
});
updateIncFiles();
setSelectedFiles([]);
}
useEffect(() => {
updateIncFiles();
}, []);
useEffect(() => {
const interval = setInterval(() => {
if (open) updateIncFiles();
}, 5000);
return () => clearInterval(interval);
}, [open]);
return (
<Modal title={<Title>Pending Files</Title>} size='auto' opened={open} onClose={onClose}>
<MutedText size='xs'>Refreshing every 5 seconds...</MutedText>
<DataTable
withBorder
borderRadius='md'
highlightOnHover
verticalSpacing='sm'
minHeight={200}
records={incFiles ?? []}
columns={[
{ accessor: 'id', title: 'ID' },
{ accessor: 'createdAt', render: (file) => new Date(file.createdAt).toLocaleString() },
{ accessor: 'status', render: (file) => file.status.toLowerCase() },
{
accessor: 'progress',
title: 'Progress',
render: (file) => `${file.chunksComplete}/${file.chunks} chunks`,
},
{
accessor: 'message',
render: (file) =>
file.data.code === 200 ? (
<AnchorNext href={file.data.message} target='_blank'>
view file
</AnchorNext>
) : (
file.data.message
),
},
]}
fetching={loading}
loaderBackgroundBlur={5}
loaderVariant='dots'
onSelectedRecordsChange={setSelectedFiles}
selectedRecords={selectedFiles}
/>
{selectedFiles.length ? (
<Tooltip label='Clearing pending files will still leave the final file on the server.'>
<Button
variant='filled'
my='md'
color='red'
onClick={deleteIncFiles}
leftIcon={<IconTrash size='1rem' />}
fullWidth
>
Clear {selectedFiles.length} pending file{selectedFiles.length > 1 ? 's' : ''}
</Button>
</Tooltip>
) : null}
</Modal>
);
}

View File

@@ -1,17 +1,20 @@
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Title } from '@mantine/core';
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Title, Tooltip } from '@mantine/core';
import { IconFileUpload, IconPhotoUp } from '@tabler/icons-react';
import File from 'components/File';
import { PlusIcon } from 'components/icons';
import useFetch from 'hooks/useFetch';
import { usePaginatedFiles } from 'lib/queries/files';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import FilePagation from './FilePagation';
import PendingFilesModal from './PendingFilesModal';
export default function Files({ disableMediaPreview, exifEnabled, queryPage }) {
export default function Files({ disableMediaPreview, exifEnabled, queryPage, compress }) {
const [favoritePage, setFavoritePage] = useState(1);
const [favoriteNumPages, setFavoriteNumPages] = useState(0);
const favoritePages = usePaginatedFiles(favoritePage, 'media', true);
const [open, setOpen] = useState(false);
useEffect(() => {
(async () => {
const { count } = await useFetch('/api/user/paged?count=true&filter=media&favorite=true');
@@ -21,13 +24,19 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage }) {
return (
<>
<PendingFilesModal open={open} onClose={() => setOpen(false)} />
<Group mb='md'>
<Title>Files</Title>
<Link href='/dashboard/upload/file' passHref legacyBehavior>
<ActionIcon component='a' variant='filled' color='primary'>
<PlusIcon />
<ActionIcon component={Link} href='/dashboard/upload/file' variant='filled' color='primary'>
<IconFileUpload size='1rem' />
</ActionIcon>
<Tooltip label='View pending uploads'>
<ActionIcon onClick={() => setOpen(true)} variant='filled' color='primary'>
<IconPhotoUp size='1rem' />
</ActionIcon>
</Link>
</Tooltip>
</Group>
{favoritePages.isSuccess && favoritePages.data.length ? (
<Accordion
@@ -50,6 +59,7 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage }) {
disableMediaPreview={disableMediaPreview}
exifEnabled={exifEnabled}
refreshImages={favoritePages.refetch}
onDash={compress}
/>
</div>
))
@@ -64,7 +74,7 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage }) {
paddingBottom: 3,
}}
>
<Pagination total={favoriteNumPages} page={favoritePage} onChange={setFavoritePage} />
<Pagination total={favoriteNumPages} value={favoritePage} onChange={setFavoritePage} />
</Box>
</Accordion.Panel>
</Accordion.Item>
@@ -75,6 +85,7 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage }) {
disableMediaPreview={disableMediaPreview}
exifEnabled={exifEnabled}
queryPage={queryPage}
compress={compress}
/>
</>
);

View File

@@ -1,7 +1,7 @@
import { Button, Group, Modal, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { showNotification } from '@mantine/notifications';
import { CrossIcon, FolderIcon } from 'components/icons';
import { IconFolderPlus, IconFolderX } from '@tabler/icons-react';
import MutedText from 'components/MutedText';
import useFetch from 'hooks/useFetch';
import { useRouter } from 'next/router';
@@ -25,14 +25,14 @@ export default function CreateFolderModal({ open, setOpen, updateFolders, create
showNotification({
title: 'Failed to create folder',
message: res.error,
icon: <CrossIcon />,
icon: <IconFolderX size='1rem' />,
color: 'red',
});
} else {
showNotification({
title: 'Created folder ' + res.name,
message: createWithFile ? 'Added file to folder' : undefined,
icon: <FolderIcon />,
icon: <IconFolderPlus size='1rem' />,
color: 'green',
});
@@ -43,6 +43,7 @@ export default function CreateFolderModal({ open, setOpen, updateFolders, create
setOpen(false);
updateFolders();
form.setValues({ name: '' });
};
return (

View File

@@ -3,7 +3,14 @@ import File from 'components/File';
import MutedText from 'components/MutedText';
import { useFolder } from 'lib/queries/folders';
export default function ViewFolderFilesModal({ open, setOpen, folderId, disableMediaPreview, exifEnabled }) {
export default function ViewFolderFilesModal({
open,
setOpen,
folderId,
disableMediaPreview,
exifEnabled,
compress,
}) {
if (!folderId) return null;
const folder = useFolder(folderId, true);
@@ -26,6 +33,7 @@ export default function ViewFolderFilesModal({ open, setOpen, folderId, disableM
image={file}
exifEnabled={exifEnabled}
refreshImages={folder.refetch}
onDash={compress}
/>
))}
</SimpleGrid>

View File

@@ -2,18 +2,36 @@ import { ActionIcon, Avatar, Card, Group, SimpleGrid, Skeleton, Stack, Title, To
import { useClipboard } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { DeleteIcon, FileIcon, PlusIcon, LockIcon, UnlockIcon, LinkIcon, CopyIcon } from 'components/icons';
import Link from 'components/Link';
import {
IconClipboardCheck,
IconClipboardCopy,
IconExternalLink,
IconFiles,
IconFolderCancel,
IconFolderMinus,
IconFolderPlus,
IconFolderShare,
IconFolderX,
IconGridDots,
IconList,
IconLock,
IconLockCancel,
IconLockOpen,
} from '@tabler/icons-react';
import AnchorNext from 'components/AnchorNext';
import MutedText from 'components/MutedText';
import useFetch from 'hooks/useFetch';
import { useFolders } from 'lib/queries/folders';
import { listViewFoldersSelector } from 'lib/recoil/settings';
import { relativeTime } from 'lib/utils/client';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
import CreateFolderModal from './CreateFolderModal';
import ViewFolderFilesModal from './ViewFolderFilesModal';
export default function Folders({ disableMediaPreview, exifEnabled }) {
export default function Folders({ disableMediaPreview, exifEnabled, compress }) {
const folders = useFolders();
const [createOpen, setCreateOpen] = useState(false);
const [createWithFile, setCreateWithFile] = useState(null);
@@ -24,6 +42,32 @@ export default function Folders({ disableMediaPreview, exifEnabled }) {
const clipboard = useClipboard();
const router = useRouter();
const [listView, setListView] = useRecoilState(listViewFoldersSelector);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
columnAccessor: 'updatedAt',
direction: 'desc',
});
const [records, setRecords] = useState(folders.data);
useEffect(() => {
setRecords(folders.data);
}, [folders.data]);
useEffect(() => {
if (!records || records.length === 0) return;
const sortedRecords = [...records].sort((a, b) => {
if (sortStatus.direction === 'asc') {
return a[sortStatus.columnAccessor] > b[sortStatus.columnAccessor] ? 1 : -1;
}
return a[sortStatus.columnAccessor] < b[sortStatus.columnAccessor] ? 1 : -1;
});
setRecords(sortedRecords);
}, [sortStatus]);
useEffect(() => {
if (router.query.create) {
setCreateOpen(true);
@@ -50,7 +94,7 @@ export default function Folders({ disableMediaPreview, exifEnabled }) {
title: 'Deleted folder',
message: `Deleted folder ${folder.name}`,
color: 'green',
icon: <DeleteIcon />,
icon: <IconFolderMinus size='1rem' />,
});
folders.refetch();
} else {
@@ -58,7 +102,7 @@ export default function Folders({ disableMediaPreview, exifEnabled }) {
title: 'Failed to delete folder',
message: res.error,
color: 'red',
icon: <DeleteIcon />,
icon: <IconFolderCancel size='1rem' />,
});
folders.refetch();
}
@@ -76,7 +120,7 @@ export default function Folders({ disableMediaPreview, exifEnabled }) {
title: 'Made folder public',
message: `Made folder ${folder.name} ${folder.public ? 'private' : 'public'}`,
color: 'green',
icon: <UnlockIcon />,
icon: <IconLockOpen size='1rem' />,
});
folders.refetch();
} else {
@@ -84,7 +128,7 @@ export default function Folders({ disableMediaPreview, exifEnabled }) {
title: 'Failed to make folder public/private',
message: res.error,
color: 'red',
icon: <UnlockIcon />,
icon: <IconLockCancel size='1rem' />,
});
folders.refetch();
}
@@ -104,102 +148,263 @@ export default function Folders({ disableMediaPreview, exifEnabled }) {
folderId={activeFolderId}
disableMediaPreview={disableMediaPreview}
exifEnabled={exifEnabled}
compress={compress}
/>
<Group mb='md'>
<Title>Folders</Title>
<ActionIcon onClick={() => setCreateOpen(!createOpen)} component='a' variant='filled' color='primary'>
<PlusIcon />
<IconFolderPlus size='1rem' />
</ActionIcon>
<Tooltip label={listView ? 'Switch to grid view' : 'Switch to list view'}>
<ActionIcon variant='filled' color='primary' onClick={() => setListView(!listView)}>
{listView ? <IconList size='1rem' /> : <IconGridDots size='1rem' />}
</ActionIcon>
</Tooltip>
</Group>
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{folders.isSuccess
? folders.data.length
? folders.data.map((folder) => (
<Card key={folder.id}>
<Group position='apart'>
<Group position='left'>
<Avatar size='lg' color='primary'>
{folder.id}
</Avatar>
<Stack spacing={0}>
<Title>{folder.name}</Title>
<MutedText size='sm'>ID: {folder.id}</MutedText>
<MutedText size='sm'>Public: {folder.public ? 'Yes' : 'No'}</MutedText>
<Tooltip label={new Date(folder.createdAt).toLocaleString()}>
<div>
<MutedText size='sm'>
Created {relativeTime(new Date(folder.createdAt))}
</MutedText>
</div>
</Tooltip>
<Tooltip label={new Date(folder.updatedAt).toLocaleString()}>
<div>
<MutedText size='sm'>
Last updated {relativeTime(new Date(folder.updatedAt))}
</MutedText>
</div>
</Tooltip>
</Stack>
{listView ? (
<DataTable
withBorder
borderRadius='md'
highlightOnHover
verticalSpacing='sm'
columns={[
{ accessor: 'id', title: 'ID', sortable: true },
{ accessor: 'name', sortable: true },
{
accessor: 'public',
sortable: true,
render: (folder) => (folder.public ? 'Public' : 'Private'),
},
{
accessor: 'createdAt',
title: 'Created',
sortable: true,
render: (folder) => new Date(folder.createdAt).toLocaleString(),
},
{
accessor: 'updatedAt',
title: 'Last updated',
sortable: true,
render: (folder) => new Date(folder.updatedAt).toLocaleString(),
},
{
accessor: 'actions',
textAlignment: 'right',
render: (folder) => (
<Group spacing={4} position='right' noWrap>
<Tooltip label='View files in folder'>
<ActionIcon
onClick={() => {
setViewOpen(true);
setActiveFolderId(folder.id);
}}
variant='subtle'
color='primary'
>
<IconFiles size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label={folder.public ? 'Make folder private' : 'Make folder public'}>
<ActionIcon onClick={() => makePublic(folder)} variant='subtle' color='primary'>
{folder.public ? <IconLockOpen size='1rem' /> : <IconLock size='1rem' />}
</ActionIcon>
</Tooltip>
<Tooltip label='Open folder in new tab'>
<ActionIcon
onClick={() => window.open(`/folder/${folder.id}`, '_blank')}
variant='subtle'
color='primary'
>
<IconFolderShare size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Copy folder link'>
<ActionIcon
onClick={() => {
clipboard.copy(`${window.location.origin}/folder/${folder.id}`);
showNotification({
title: 'Copied folder link',
message: 'Copied folder link to clipboard',
color: 'green',
icon: <IconClipboardCheck size='1rem' />,
});
}}
variant='subtle'
color='primary'
>
<IconClipboardCopy size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Delete folder'>
<ActionIcon onClick={() => deleteFolder(folder)} variant='subtle' color='red'>
<IconFolderX size='1rem' />
</ActionIcon>
</Tooltip>
</Group>
),
},
]}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
records={records ?? []}
fetching={folders.isLoading}
loaderBackgroundBlur={5}
minHeight='calc(100vh - 200px)'
loaderVariant='dots'
rowContextMenu={{
shadow: 'xl',
borderRadius: 'md',
items: (folder) => [
{
key: 'viewFiles',
title: 'View files in folder',
icon: <IconFiles size='1rem' />,
onClick: () => {
setViewOpen(true);
setActiveFolderId(folder.id);
},
},
{
key: 'makePublic',
title: folder.public ? 'Make folder private' : 'Make folder public',
icon: folder.public ? <IconLockOpen size='1rem' /> : <IconLock size='1rem' />,
onClick: () => makePublic(folder),
},
{
key: 'openFolder',
title: 'Open folder in a new tab',
icon: <IconExternalLink size='1rem' />,
onClick: () => window.open(`/folder/${folder.id}`, '_blank'),
},
{
key: 'copyLink',
title: 'Copy folder link to clipboard',
icon: <IconClipboardCopy size='1rem' />,
onClick: () => {
clipboard.copy(`${window.location.origin}/folder/${folder.id}`);
},
},
{
key: 'deleteFolder',
title: 'Delete folder',
icon: <IconFolderX size='1rem' />,
onClick: () => deleteFolder(folder),
},
],
}}
onCellClick={({ column, record: folder }) => {
if (column.accessor === 'actions') return;
setViewOpen(true);
setActiveFolderId(folder.id);
}}
/>
) : (
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{folders.isSuccess
? folders.data.length
? folders.data.map((folder) => (
<Card key={folder.id}>
<Group position='apart'>
<Group position='left'>
<Avatar size='lg' color='primary'>
{folder.id}
</Avatar>
<Stack spacing={0}>
<Title>{folder.name}</Title>
<MutedText size='sm'>ID: {folder.id}</MutedText>
<MutedText size='sm'>Public: {folder.public ? 'Yes' : 'No'}</MutedText>
<Tooltip label={new Date(folder.createdAt).toLocaleString()}>
<div>
<MutedText size='sm'>
Created {relativeTime(new Date(folder.createdAt))}
</MutedText>
</div>
</Tooltip>
<Tooltip label={new Date(folder.updatedAt).toLocaleString()}>
<div>
<MutedText size='sm'>
Last updated {relativeTime(new Date(folder.updatedAt))}
</MutedText>
</div>
</Tooltip>
</Stack>
</Group>
<Group>
<Stack>
<Tooltip label={folder.public ? 'Make folder private' : 'Make folder public'}>
<ActionIcon
aria-label={folder.public ? 'make private' : 'make public'}
onClick={() => makePublic(folder)}
>
{folder.public ? <IconLock size='1rem' /> : <IconLockOpen size='1rem' />}
</ActionIcon>
</Tooltip>
<Tooltip label='Delete folder'>
<ActionIcon aria-label='delete' onClick={() => deleteFolder(folder)}>
<IconFolderMinus size='1rem' />
</ActionIcon>
</Tooltip>
</Stack>
<Stack>
<ActionIcon
aria-label='view files'
onClick={() => {
setViewOpen(!viewOpen);
setActiveFolderId(folder.id);
}}
>
<IconFiles size='1rem' />
</ActionIcon>
<ActionIcon
aria-label='copy link'
onClick={() => {
clipboard.copy(`${window.location.origin}/folder/${folder.id}`);
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
else
showNotification({
title: 'Copied folder link',
message: (
<>
Copied{' '}
<AnchorNext href={`/folder/${folder.id}`}>folder link</AnchorNext> to
clipboard
</>
),
color: 'green',
icon: <IconClipboardCopy size='1rem' />,
});
}}
>
<IconClipboardCopy size='1rem' />
</ActionIcon>
<ActionIcon
aria-label='open in new tab'
onClick={() => window.open(`/folder/${folder.id}`)}
>
<IconFolderShare size='1rem' />
</ActionIcon>
</Stack>
</Group>
</Group>
<Group>
<Tooltip label={folder.public ? 'Make folder private' : 'Make folder public'}>
<ActionIcon
aria-label={folder.public ? 'make private' : 'make public'}
onClick={() => makePublic(folder)}
>
{folder.public ? <LockIcon /> : <UnlockIcon />}
</ActionIcon>
</Tooltip>
<ActionIcon
aria-label='view files'
onClick={() => {
setViewOpen(!viewOpen);
setActiveFolderId(folder.id);
}}
>
<FileIcon />
</ActionIcon>
<ActionIcon
aria-label='copy link'
onClick={() => {
clipboard.copy(`${window.location.origin}/folder/${folder.id}`);
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
else
showNotification({
title: 'Copied folder link',
message: (
<>
Copied <Link href={`/folder/${folder.id}`}>folder link</Link> to clipboard
</>
),
color: 'green',
icon: <CopyIcon />,
});
}}
>
<LinkIcon />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => deleteFolder(folder)}>
<DeleteIcon />
</ActionIcon>
</Group>
</Group>
</Card>
))
: null
: [1, 2, 3, 4].map((x) => (
<div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
</div>
))}
</SimpleGrid>
</Card>
))
: null
: [1, 2, 3, 4].map((x) => (
<div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
</div>
))}
</SimpleGrid>
)}
</>
);
}

View File

@@ -17,12 +17,24 @@ import { useForm } from '@mantine/form';
import { useClipboard } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { CopyIcon, CrossIcon, DeleteIcon, PlusIcon, TagIcon } from 'components/icons';
import type { Invite } from '@prisma/client';
import {
IconClipboardCopy,
IconGridDots,
IconList,
IconPlus,
IconTag,
IconTagOff,
IconTrash,
} from '@tabler/icons-react';
import MutedText from 'components/MutedText';
import useFetch from 'hooks/useFetch';
import { listViewInvitesSelector } from 'lib/recoil/settings';
import { expireText, relativeTime } from 'lib/utils/client';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
const expires = ['30m', '1h', '6h', '12h', '1d', '3d', '5d', '7d', 'never'];
@@ -65,14 +77,14 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
showNotification({
title: 'Failed to create invite',
message: res.error,
icon: <CrossIcon />,
icon: <IconTagOff size='1rem' />,
color: 'red',
});
} else {
showNotification({
title: 'Created invite',
message: '',
icon: <TagIcon />,
icon: <IconTag size='1rem' />,
color: 'green',
});
}
@@ -125,29 +137,56 @@ export default function Invites() {
const modals = useModals();
const clipboard = useClipboard();
const [invites, setInvites] = useState([]);
const [invites, setInvites] = useState<Invite[]>([]);
const [open, setOpen] = useState(false);
const [ok, setOk] = useState(false);
const [listView, setListView] = useRecoilState(listViewInvitesSelector);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
columnAccessor: 'createdAt',
direction: 'asc',
});
const [records, setRecords] = useState(invites);
useEffect(() => {
setRecords(invites);
}, [invites]);
useEffect(() => {
if (!records || records.length === 0) return;
const sortedRecords = [...records].sort((a, b) => {
if (sortStatus.direction === 'asc') {
return a[sortStatus.columnAccessor] > b[sortStatus.columnAccessor] ? 1 : -1;
}
return a[sortStatus.columnAccessor] < b[sortStatus.columnAccessor] ? 1 : -1;
});
setRecords(sortedRecords);
}, [sortStatus]);
const openDeleteModal = (invite) =>
modals.openConfirmModal({
title: `Delete ${invite.code}?`,
centered: true,
overlayBlur: 3,
overlayProps: { blur: 3 },
labels: { confirm: 'Yes', cancel: 'No' },
onConfirm: async () => {
const res = await useFetch(`/api/auth/invite?code=${invite.code}`, 'DELETE');
if (res.error) {
showNotification({
title: 'Failed to delete invite ${invite.code}',
title: `Failed to delete invite ${invite.code}`,
message: res.error,
icon: <CrossIcon />,
icon: <IconTagOff size='1rem' />,
color: 'red',
});
} else {
showNotification({
title: `Deleted invite ${invite.code}`,
message: '',
icon: <DeleteIcon />,
icon: <IconTag size='1rem' />,
color: 'green',
});
}
@@ -168,7 +207,7 @@ export default function Invites() {
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
icon: <IconClipboardCopy size='1rem' />,
});
};
@@ -176,6 +215,7 @@ export default function Invites() {
const us = await useFetch('/api/auth/invite');
if (!us.error) {
setInvites(us);
setOk(true);
} else {
router.push('/dashboard');
}
@@ -191,48 +231,134 @@ export default function Invites() {
<Group mb='md'>
<Title>Invites</Title>
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}>
<PlusIcon />
<IconPlus size='1rem' />
</ActionIcon>
<Tooltip label={listView ? 'Switch to grid view' : 'Switch to list view'}>
<ActionIcon variant='filled' color='primary' onClick={() => setListView(!listView)}>
{listView ? <IconList size='1rem' /> : <IconGridDots size='1rem' />}
</ActionIcon>
</Tooltip>
</Group>
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{invites.length
? invites.map((invite) => (
<Card key={invite.id} sx={{ maxWidth: '100%' }}>
<Group position='apart'>
<Group position='left'>
<Avatar size='lg' color={invite.used ? 'dark' : 'primary'}>
{invite.id}
</Avatar>
<Stack spacing={0}>
<Title>
{invite.code}
{invite.used && <> (Used)</>}
</Title>
<Tooltip label={new Date(invite.createdAt).toLocaleString()}>
<div>
<MutedText size='sm'>Created {relativeTime(new Date(invite.createdAt))}</MutedText>
</div>
</Tooltip>
<Tooltip label={new Date(invite.expiresAt).toLocaleString()}>
<div>
<MutedText size='sm'>{expireText(invite.expiresAt)}</MutedText>
</div>
</Tooltip>
{listView ? (
<DataTable
withBorder
borderRadius='md'
highlightOnHover
verticalSpacing='sm'
columns={[
{ accessor: 'id', sortable: true },
{ accessor: 'code', sortable: true },
{
accessor: 'createdAt',
title: 'Created At',
sortable: true,
render: (invite) => new Date(invite.createdAt).toLocaleString(),
},
{
accessor: 'expiresAt',
title: 'Expires At',
sortable: true,
render: (invite) => new Date(invite.expiresAt).toLocaleString(),
},
{
accessor: 'used',
sortable: true,
render: (invite) => (invite.used ? 'Yes' : 'No'),
},
{
accessor: 'actions',
textAlignment: 'right',
render: (invite) => (
<Group spacing={4} position='right' noWrap>
<Tooltip label='Copy invite link'>
<ActionIcon variant='subtle' color='primary' onClick={() => handleCopy(invite)}>
<IconClipboardCopy size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Delete invite'>
<ActionIcon variant='subtle' color='red' onClick={() => openDeleteModal(invite)}>
<IconTrash size='1rem' />
</ActionIcon>
</Tooltip>
</Group>
),
},
]}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
records={records ?? []}
fetching={!ok}
minHeight='calc(100vh - 200px)'
loaderBackgroundBlur={5}
loaderVariant='dots'
rowContextMenu={{
shadow: 'xl',
borderRadius: 'md',
items: (invite) => [
{
key: 'copy',
icon: <IconClipboardCopy size='1rem' />,
title: `Copy invite code: "${invite.code}"`,
onClick: () => clipboard.copy(invite.code),
},
{
key: 'copyLink',
icon: <IconClipboardCopy size='1rem' />,
title: 'Copy invite link',
onClick: () => handleCopy(invite),
},
{
key: 'delete',
icon: <IconTrash size='1rem' />,
title: `Delete invite ${invite.code}`,
onClick: () => openDeleteModal(invite),
},
],
}}
/>
) : (
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{invites.length
? invites.map((invite) => (
<Card key={invite.id} sx={{ maxWidth: '100%' }}>
<Group position='apart'>
<Group position='left'>
<Avatar size='lg' color={invite.used ? 'dark' : 'primary'}>
{invite.id}
</Avatar>
<Stack spacing={0}>
<Title>
{invite.code}
{invite.used && <> (Used)</>}
</Title>
<Tooltip label={new Date(invite.createdAt).toLocaleString()}>
<div>
<MutedText size='sm'>
Created {relativeTime(new Date(invite.createdAt))}
</MutedText>
</div>
</Tooltip>
<Tooltip label={new Date(invite.expiresAt).toLocaleString()}>
<div>
<MutedText size='sm'>{expireText(invite.expiresAt.toString())}</MutedText>
</div>
</Tooltip>
</Stack>
</Group>
<Stack>
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
<IconClipboardCopy size='1rem' />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
<IconTrash size='1rem' />
</ActionIcon>
</Stack>
</Group>
<Stack>
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
<CopyIcon />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
<DeleteIcon />
</ActionIcon>
</Stack>
</Group>
</Card>
))
: [1, 2, 3].map((x) => <Skeleton key={x} width='100%' height={100} radius='sm' />)}
</SimpleGrid>
</Card>
))
: [1, 2, 3].map((x) => <Skeleton key={x} width='100%' height={100} radius='sm' />)}
</SimpleGrid>
)}
</>
);
}

View File

@@ -1,10 +1,12 @@
import { Button, Checkbox, Group, Modal, Text, Title } from '@mantine/core';
import { closeAllModals, openConfirmModal } from '@mantine/modals';
import { showNotification, updateNotification } from '@mantine/notifications';
import { CheckIcon, CrossIcon } from 'components/icons';
import { IconFiles, IconFilesOff } from '@tabler/icons-react';
import useFetch from 'hooks/useFetch';
import { useState } from 'react';
export default function ClearStorage({ open, setOpen, check, setCheck }) {
export default function ClearStorage({ open, setOpen }) {
const [check, setCheck] = useState(false);
const handleDelete = async (datasource: boolean, orphaned?: boolean) => {
showNotification({
id: 'clear-uploads',
@@ -22,7 +24,7 @@ export default function ClearStorage({ open, setOpen, check, setCheck }) {
title: 'Error while clearing uploads',
message: res.error,
color: 'red',
icon: <CrossIcon />,
icon: <IconFilesOff size='1rem' />,
});
} else {
updateNotification({
@@ -30,7 +32,7 @@ export default function ClearStorage({ open, setOpen, check, setCheck }) {
title: 'Successfully cleared uploads',
message: '',
color: 'green',
icon: <CheckIcon />,
icon: <IconFiles size='1rem' />,
});
}
};
@@ -38,7 +40,10 @@ export default function ClearStorage({ open, setOpen, check, setCheck }) {
return (
<Modal
opened={open}
onClose={() => setOpen(false)}
onClose={() => {
setOpen(false);
setCheck(() => false);
}}
title={<Title size='sm'>Are you sure you want to clear all uploads in the database?</Title>}
>
<Checkbox

View File

@@ -1,5 +1,5 @@
import { Code } from '@mantine/core';
import Link from 'components/Link';
import AnchorNext from 'components/AnchorNext';
import { GeneratorModal } from './GeneratorModal';
export default function Flameshot({ user, open, setOpen }) {
@@ -105,18 +105,18 @@ ${curl.join(' ')} -d "{\\"url\\": \\"$arg\\"}"${values.noJSON ? '' : " | jq -r '
title='Flameshot'
desc={
<>
To use this script, you need <Link href='https://flameshot.org'>Flameshot</Link>,{' '}
<Link href='https://curl.se/'>
To use this script, you need <AnchorNext href='https://flameshot.org'>Flameshot</AnchorNext>,{' '}
<AnchorNext href='https://curl.se/'>
<Code>curl</Code>
</Link>
</AnchorNext>
,{' '}
<Link href='https://github.com/stedolan/jq'>
<AnchorNext href='https://github.com/stedolan/jq'>
<Code>jq</Code>
</Link>
</AnchorNext>
, and{' '}
<Link href='https://github.com/kfish/xsel'>
<AnchorNext href='https://github.com/kfish/xsel'>
<Code>xsel</Code>
</Link>{' '}
</AnchorNext>{' '}
installed. This script is intended for use on Linux only.
</>
}

View File

@@ -14,8 +14,8 @@ import {
Title,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { DownloadIcon, GlobeIcon } from 'components/icons';
import Link from 'components/Link';
import { IconFileDownload, IconWorld } from '@tabler/icons-react';
import AnchorNext from 'components/AnchorNext';
import MutedText from 'components/MutedText';
import { useReducer, useState } from 'react';
@@ -103,10 +103,11 @@ export function GeneratorModal({ opened, onClose, title, onSubmit, ...other }) {
<Select
label='Select file name format'
data={[
{ value: 'RANDOM', label: 'Random (alphanumeric)' },
{ value: 'DATE', label: 'Date' },
{ value: 'UUID', label: 'UUID' },
{ value: 'NAME', label: 'Name (keeps original file name)' },
{ value: 'random', label: 'Random (alphanumeric)' },
{ value: 'date', label: 'Date' },
{ value: 'uuid', label: 'UUID' },
{ value: 'name', label: 'Name (keeps original file name)' },
{ value: 'gfycat', label: 'Gfycat' },
]}
id='format'
my='sm'
@@ -128,7 +129,7 @@ export function GeneratorModal({ opened, onClose, title, onSubmit, ...other }) {
<TextInput
label='Override Domain'
onChange={handleOD}
icon={<GlobeIcon />}
icon={<IconWorld size='1rem' />}
description={odState.description}
error={odState.error}
/>
@@ -168,9 +169,9 @@ export function GeneratorModal({ opened, onClose, title, onSubmit, ...other }) {
<Text>Wayland</Text>
<MutedText size='sm'>
If using wayland, you can check the boxes below to your liking. This will require{' '}
<Link href='https://github.com/bugaevc/wl-clipboard'>
<AnchorNext href='https://github.com/bugaevc/wl-clipboard'>
<Code>wl-clipboard</Code>
</Link>{' '}
</AnchorNext>{' '}
for the <Code>wl-copy</Code> command.
</MutedText>
</Box>
@@ -196,8 +197,8 @@ export function GeneratorModal({ opened, onClose, title, onSubmit, ...other }) {
description={
<>
If using a compositor such as{' '}
<Link href='https://github.com/hyprwm/hyprland'>Hyprland</Link>, this option will set the{' '}
<Code>XDG_CURRENT_DESKTOP=sway</Code> to workaround Flameshot&apos;s errors
<AnchorNext href='https://github.com/hyprwm/hyprland'>Hyprland</AnchorNext>, this option
will set the <Code>XDG_CURRENT_DESKTOP=sway</Code> to workaround Flameshot&apos;s errors
</>
}
disabled={!isUploadFile}
@@ -211,7 +212,7 @@ export function GeneratorModal({ opened, onClose, title, onSubmit, ...other }) {
<Group grow my='md'>
<Button onClick={form.reset}>Reset</Button>
<Button rightIcon={<DownloadIcon />} type='submit'>
<Button rightIcon={<IconFileDownload size='1rem' />} type='submit'>
Download
</Button>
</Group>

View File

@@ -1,4 +1,3 @@
import { useReducer, useState } from 'react';
import { GeneratorModal } from './GeneratorModal';
export default function ShareX({ user, open, setOpen }) {

View File

@@ -1,17 +1,14 @@
import { Button, Center, Image, Modal, NumberInput, Text, Title } from '@mantine/core';
import { Button, Center, Image, Modal, PinInput, Text, Title } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { useForm } from '@mantine/form';
import { CheckIcon, CrossIcon } from 'components/icons';
import { Icon2fa, IconBarcodeOff, IconCheck } from '@tabler/icons-react';
import useFetch from 'hooks/useFetch';
import { useEffect, useState } from 'react';
export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
export function TotpModal({ opened, onClose, deleteTotp, setUser }) {
const [secret, setSecret] = useState('');
const [qrCode, setQrCode] = useState('');
const [disabled, setDisabled] = useState(false);
const [code, setCode] = useState(undefined);
const [error, setError] = useState('');
const form = useForm();
useEffect(() => {
(async () => {
@@ -23,7 +20,7 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
title: 'Error',
message: "Can't generate code as you are already using MFA",
color: 'red',
icon: <CrossIcon />,
icon: <IconBarcodeOff size='1rem' />,
});
} else {
setSecret(data.secret);
@@ -34,15 +31,15 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
})();
}, [opened]);
const disableTotp = async () => {
const disableTotp = async (code) => {
setDisabled(true);
const str = code.toString();
if (str.length !== 6) {
if (code.length !== 6) {
setDisabled(false);
return setError('Code must be 6 digits');
}
const resp = await useFetch('/api/user/mfa/totp', 'DELETE', {
code: str,
code,
});
if (resp.error) {
@@ -50,29 +47,28 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
} else {
showNotification({
title: 'Success',
message: 'Successfully disabled MFA',
message: 'Successfully disabled 2FA',
color: 'green',
icon: <CheckIcon />,
icon: <Icon2fa size='1rem' />,
});
setTotpEnabled(false);
setUser((user) => ({ ...user, totpSecret: null }));
onClose();
}
setDisabled(false);
};
const verifyCode = async () => {
const verifyCode = async (code) => {
setDisabled(true);
const str = code.toString();
if (str.length !== 6) {
if (code.length !== 6) {
setDisabled(false);
return setError('Code must be 6 digits');
}
const resp = await useFetch('/api/user/mfa/totp', 'POST', {
secret,
code: str,
code,
register: true,
});
@@ -81,19 +77,25 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
} else {
showNotification({
title: 'Success',
message: 'Successfully enabled MFA',
message: 'Successfully enabled 2FA',
color: 'green',
icon: <CheckIcon />,
icon: <Icon2fa size='1rem' />,
});
setTotpEnabled(true);
setUser((user) => ({ ...user, totpSecret: secret }));
onClose();
}
setDisabled(false);
};
const handlePinChange = (value) => {
if (value.length === 6) {
setDisabled(true);
deleteTotp ? disableTotp(value) : verifyCode(value);
}
};
return (
<Modal
opened={opened}
@@ -112,39 +114,46 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
<Center>
<Image height={180} width={180} src={qrCode} alt='QR Code' withPlaceholder />
</Center>
<Text my='sm'>QR Code not working? Try manually entering the code into your app: {secret}</Text>
</>
)}
<form
onSubmit={form.onSubmit(() => {
deleteTotp ? disableTotp() : verifyCode();
})}
>
<NumberInput
placeholder='2FA Code'
label='Verify'
size='xl'
hideControls
maxLength={6}
minLength={6}
value={code}
onChange={(e) => setCode(e)}
<Center my='md'>
<PinInput
data-autofocus
error={error}
/>
<Button
length={6}
oneTimeCode
type='number'
placeholder=''
onChange={handlePinChange}
autoFocus={true}
error={!!error}
disabled={disabled}
size='lg'
fullWidth
mt='md'
rightIcon={<CheckIcon />}
onClick={deleteTotp ? disableTotp : verifyCode}
>
Verify{deleteTotp ? ' and Disable' : ''}
</Button>
</form>
size='xl'
/>
</Center>
{error && (
<Text my='sm' size='sm' color='red' align='center'>
{error}
</Text>
)}
{!deleteTotp && (
<Text my='sm' size='sm' color='gray' align='center'>
QR Code not working? Try manually entering the code into your app: {secret}
</Text>
)}
<Button
disabled={disabled}
size='lg'
fullWidth
mt='md'
rightIcon={<IconCheck size='1rem' />}
type='submit'
>
Verify{deleteTotp ? ' and Disable' : ''}
</Button>
</Modal>
);
}

View File

@@ -20,26 +20,33 @@ import { randomId, useInterval, useMediaQuery } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { showNotification, updateNotification } from '@mantine/notifications';
import {
CheckIcon,
CrossIcon,
DeleteIcon,
DiscordIcon,
FlameshotIcon,
GitHubIcon,
GoogleIcon,
RefreshIcon,
SettingsIcon,
ShareXIcon,
} from 'components/icons';
import DownloadIcon from 'components/icons/DownloadIcon';
import TrashIcon from 'components/icons/TrashIcon';
import Link from 'components/Link';
IconBrandDiscordFilled,
IconBrandGithubFilled,
IconBrandGoogle,
IconFileExport,
IconFiles,
IconFilesOff,
IconFileZip,
IconGraph,
IconGraphOff,
IconPhotoMinus,
IconReload,
IconTrash,
IconUserCheck,
IconUserCog,
IconUserExclamation,
IconUserMinus,
IconUserX,
} from '@tabler/icons-react';
import AnchorNext from 'components/AnchorNext';
import { FlameshotIcon, ShareXIcon } from 'components/icons';
import MutedText from 'components/MutedText';
import { SmallTable } from 'components/SmallTable';
import useFetch from 'hooks/useFetch';
import { userSelector } from 'lib/recoil/user';
import { bytesToHuman } from 'lib/utils/bytes';
import { capitalize } from 'lib/utils/client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
import ClearStorage from './ClearStorage';
@@ -62,9 +69,9 @@ function ExportDataTooltip({ children }) {
export default function Manage({ oauth_registration, oauth_providers: raw_oauth_providers, totp_enabled }) {
const oauth_providers = JSON.parse(raw_oauth_providers);
const icons = {
Discord: DiscordIcon,
GitHub: GitHubIcon,
Google: GoogleIcon,
Discord: IconBrandDiscordFilled,
GitHub: IconBrandGithubFilled,
Google: IconBrandGoogle,
};
for (const provider of oauth_providers) {
@@ -79,10 +86,9 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
const [flameshotOpen, setFlameshotOpen] = useState(false);
const [clrStorOpen, setClrStorOpen] = useState(false);
const [exports, setExports] = useState([]);
const [file, setFile] = useState<File>(null);
const [file, setFile] = useState<File | null>(null);
const [fileDataURL, setFileDataURL] = useState(user.avatar ?? null);
const [totpEnabled, setTotpEnabled] = useState(!!user.totpSecret);
const [checked, setCheck] = useState(false);
const getDataURL = (f: File): Promise<string> => {
return new Promise((res, rej) => {
@@ -103,11 +109,13 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
const handleAvatarChange = async (file: File) => {
setFile(file);
setFileDataURL(await getDataURL(file));
if (file) setFileDataURL(await getDataURL(file));
};
const saveAvatar = async () => {
const dataURL = await getDataURL(file);
let dataURL = null;
if (file) dataURL = await getDataURL(file);
showNotification({
id: 'update-user',
@@ -119,6 +127,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
const newUser = await useFetch('/api/user', 'PATCH', {
avatar: dataURL,
...(!dataURL && { resetAvatar: true }),
});
if (newUser.error) {
@@ -127,7 +136,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
title: "Couldn't save user",
message: newUser.error,
color: 'red',
icon: <CrossIcon />,
icon: <IconUserX size='1rem' />,
});
} else {
setUser(newUser);
@@ -135,6 +144,8 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
id: 'update-user',
title: 'Saved User',
message: '',
color: 'green',
icon: <IconUserCheck size='1rem' />,
});
}
};
@@ -201,7 +212,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
</>
),
color: 'red',
icon: <CrossIcon />,
icon: <IconUserX size='1rem' />,
});
}
updateNotification({
@@ -209,7 +220,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
title: "Couldn't save user",
message: newUser.error,
color: 'red',
icon: <CrossIcon />,
icon: <IconUserX size='1rem' />,
});
} else {
setUser(newUser);
@@ -217,6 +228,8 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
id: 'update-user',
title: 'Saved User',
message: '',
color: 'green',
icon: <IconUserCheck size='1rem' />,
});
}
};
@@ -235,7 +248,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
title: 'Error exporting data',
message: res.error,
color: 'red',
icon: <CrossIcon />,
icon: <IconFileExport size='1rem' />,
});
}
};
@@ -264,14 +277,14 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
title: "Couldn't delete files",
message: res.error,
color: 'red',
icon: <CrossIcon />,
icon: <IconFilesOff size='1rem' />,
});
} else {
showNotification({
title: 'Deleted files',
message: `${res.count} files deleted`,
color: 'green',
icon: <DeleteIcon />,
icon: <IconFiles size='1rem' />,
});
}
};
@@ -303,14 +316,14 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
title: 'Error updating stats',
message: res.error,
color: 'red',
icon: <CrossIcon />,
icon: <IconGraphOff size='1rem' />,
});
} else {
showNotification({
title: 'Updated stats',
message: '',
color: 'green',
icon: <CheckIcon />,
icon: <IconGraph size='1rem' />,
});
}
};
@@ -324,7 +337,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
title: 'Error while unlinking from OAuth',
message: res.error,
color: 'red',
icon: <CrossIcon />,
icon: <IconUserExclamation size='1rem' />,
});
} else {
setUser(res);
@@ -332,7 +345,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
title: `Unlinked from ${provider[0] + provider.slice(1).toLowerCase()}`,
message: '',
color: 'green',
icon: <CheckIcon />,
icon: <IconUserMinus size='1rem' />,
});
}
};
@@ -341,14 +354,16 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
useEffect(() => {
getExports();
interval.start();
}, []);
setTotpEnabled(() => !!user.totpSecret);
}, [user]);
return (
<>
<Title>Manage User</Title>
<MutedText size='md'>
Want to use variables in embed text? Visit{' '}
<Link href='https://zipline.diced.tech/docs/guides/variables'>the docs</Link> for variables
<AnchorNext href='https://zipline.diced.tech/docs/guides/variables'>the docs</AnchorNext> for
variables
</MutedText>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput id='username' label='Username' my='sm' {...form.getInputProps('username')} />
@@ -413,7 +428,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
<Box my='md'>
<Title>Two Factor Authentication</Title>
<MutedText size='md'>
{user.totpSecret
{totpEnabled
? 'You have two factor authentication enabled.'
: 'You do not have two factor authentication enabled.'}
</MutedText>
@@ -435,7 +450,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
opened={totpOpen}
onClose={() => setTotpOpen(false)}
deleteTotp={totpEnabled}
setTotpEnabled={setTotpEnabled}
setUser={setUser}
/>
</Box>
)}
@@ -452,11 +467,9 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
!user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase())
)
.map(({ link_url, name, Icon }, i) => (
<Link key={i} href={link_url} passHref legacyBehavior>
<Button size='lg' leftIcon={<Icon colorScheme='manage' />} component='a' my='sm'>
Link account with {name}
</Button>
</Link>
<Button key={i} size='lg' leftIcon={<Icon />} component={Link} href={link_url} my='sm'>
Link account with {name}
</Button>
))}
{user?.oauth?.map(({ provider }, i) => (
@@ -464,7 +477,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
key={i}
onClick={() => handleOauthUnlink(provider)}
size='lg'
leftIcon={<TrashIcon />}
leftIcon={<IconTrash size='1rem' />}
my='sm'
color='red'
>
@@ -488,16 +501,18 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
<Card mt='md'>
<Text>Preview:</Text>
<Button
leftIcon={fileDataURL ? <Image src={fileDataURL} height={32} radius='md' /> : <SettingsIcon />}
sx={(t) => ({
backgroundColor: '#00000000',
'&:hover': {
backgroundColor: t.other.hover,
},
color: t.colorScheme === 'dark' ? 'white' : 'black',
})}
leftIcon={
fileDataURL ? (
<Image src={fileDataURL} height={32} width={32} radius='md' />
) : (
<IconUserCog size='1rem' />
)
}
size='xl'
p='sm'
variant='subtle'
color='gray'
compact
>
{user.username}
</Button>
@@ -523,15 +538,15 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
</Box>
<Group my='md' grow={useMediaQuery('(max-width: 768px)')}>
<Button onClick={openDeleteModal} rightIcon={<DeleteIcon />} color='red'>
<Button onClick={openDeleteModal} rightIcon={<IconPhotoMinus size='1rem' />} color='red'>
Delete All Data
</Button>
<ExportDataTooltip>
<Button onClick={exportData} rightIcon={<DownloadIcon />}>
<Button onClick={exportData} rightIcon={<IconFileZip size='1rem' />}>
Export Data
</Button>
</ExportDataTooltip>
<Button onClick={getExports} rightIcon={<RefreshIcon />}>
<Button onClick={getExports} rightIcon={<IconReload size='1rem' />}>
Refresh
</Button>
</Group>
@@ -566,10 +581,15 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
<Box mt='md'>
<Title>Server</Title>
<Group my='md' grow={useMediaQuery('(max-width: 768px)')}>
<Button size='md' onClick={forceUpdateStats} color='red' rightIcon={<RefreshIcon />}>
<Button size='md' onClick={forceUpdateStats} color='red' rightIcon={<IconReload size='1rem' />}>
Force Update Stats
</Button>
<Button size='md' onClick={() => setClrStorOpen(true)} color='red' rightIcon={<TrashIcon />}>
<Button
size='md'
onClick={() => setClrStorOpen(true)}
color='red'
rightIcon={<IconTrash size='1rem' />}
>
Delete all uploads
</Button>
</Group>
@@ -581,7 +601,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
<Button
size='xl'
onClick={() => setShareXOpen(true)}
rightIcon={<ShareXIcon />}
rightIcon={<ShareXIcon size='1rem' />}
sx={{
'@media screen and (max-width: 768px)': {
width: '100%',
@@ -593,7 +613,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
<Button
size='xl'
onClick={() => setFlameshotOpen(true)}
rightIcon={<FlameshotIcon />}
rightIcon={<FlameshotIcon size='1rem' />}
sx={{
'@media screen and (max-width: 768px)': {
width: '100%',
@@ -606,7 +626,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
<ShareX user={user} open={shareXOpen} setOpen={setShareXOpen} />
<Flameshot user={user} open={flameshotOpen} setOpen={setFlameshotOpen} />
<ClearStorage open={clrStorOpen} setOpen={setClrStorOpen} check={checked} setCheck={setCheck} />
<ClearStorage open={clrStorOpen} setOpen={setClrStorOpen} />
</>
);
}

View File

@@ -1,7 +1,7 @@
import { Button, Center, Group, Skeleton, Table, TextInput, Title } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { CopyIcon } from 'components/icons';
import { IconClipboardCopy } from '@tabler/icons-react';
import useFetch from 'hooks/useFetch';
import { useEffect, useState } from 'react';
@@ -37,7 +37,7 @@ export default function MetadataView({ fileId }) {
showNotification({
title: 'Copied to clipboard',
message: value,
icon: <CopyIcon />,
icon: <IconClipboardCopy size='1rem' />,
});
};

View File

@@ -2,7 +2,7 @@ import { Box, Card, Grid, LoadingOverlay, Title, useMantineTheme } from '@mantin
import { useStats } from 'lib/queries/stats';
import { bytesToHuman } from 'lib/utils/bytes';
import { useEffect, useMemo, useState } from 'react';
import { useMemo } from 'react';
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';

View File

@@ -3,7 +3,7 @@ import { Box, Card, Center, Grid, LoadingOverlay, Title, useMantineTheme } from
import { SmallTable } from 'components/SmallTable';
import { useStats } from 'lib/queries/stats';
import { colorHash } from 'lib/utils/client';
import { useEffect, useMemo, useState } from 'react';
import { useMemo } from 'react';
import { Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
@@ -34,9 +34,11 @@ export default function Types() {
return !latest ? (
<LoadingOverlay visible={stats.isLoading} />
) : (
<Box mt='md'>
<Box my='md'>
{latest.data.count_by_user.length ? (
<Card>
<Card my='md'>
<Title size='h4'>Top Uploaders</Title>
<SmallTable
columns={[
{ id: 'username', name: 'Name' },
@@ -46,7 +48,7 @@ export default function Types() {
/>
</Card>
) : null}
<Card>
<Card my='md'>
<Title size='h4'>Upload Types</Title>
<Grid>
<Grid.Col md={12} lg={8}>

View File

@@ -1,23 +1,10 @@
import {
Box,
Button,
Collapse,
Group,
NumberInput,
PasswordInput,
Progress,
Select,
Stack,
Text,
Title,
Tooltip,
} from '@mantine/core';
import { Button, Collapse, Group, Progress, Stack, Title } from '@mantine/core';
import { randomId, useClipboard } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { showNotification, updateNotification } from '@mantine/notifications';
import { IconFileImport, IconFileTime, IconFileUpload, IconFileX } from '@tabler/icons-react';
import Dropzone from 'components/dropzone/Dropzone';
import FileDropzone from 'components/dropzone/DropzoneFile';
import { ClockIcon, CrossIcon, UploadIcon } from 'components/icons';
import MutedText from 'components/MutedText';
import { invalidateFiles } from 'lib/queries/files';
import { userSelector } from 'lib/recoil/user';
@@ -26,8 +13,11 @@ import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import showFilesModal from './showFilesModal';
import useUploadOptions from './useUploadOptions';
import { useRouter } from 'next/router';
export default function File({ chunks: chunks_config }) {
const router = useRouter();
const clipboard = useClipboard();
const modals = useModals();
const user = useRecoilValue(userSelector);
@@ -38,17 +28,48 @@ export default function File({ chunks: chunks_config }) {
const [options, setOpened, OptionsModal] = useUploadOptions();
const beforeUnload = (e: BeforeUnloadEvent) => {
if (loading) {
e.preventDefault();
e.returnValue = "Are you sure you want to leave? Your upload(s) won't be saved.";
return e.returnValue;
}
};
const beforeRouteChange = (url: string) => {
if (loading) {
const confirmed = confirm("Are you sure you want to leave? Your upload(s) won't be saved.");
if (!confirmed) {
router.events.emit('routeChangeComplete', url);
throw 'Route change aborted';
}
}
};
useEffect(() => {
window.addEventListener('paste', (e: ClipboardEvent) => {
const listener = (e: ClipboardEvent) => {
const item = Array.from(e.clipboardData.items).find((x) => /^image/.test(x.type));
if (!item) return;
const file = item.getAsFile();
setFiles([...files, file]);
showNotification({
title: 'Image imported from clipboard',
message: '',
icon: <IconFileImport size='1rem' />,
});
});
});
};
document.addEventListener('paste', listener);
window.addEventListener('beforeunload', beforeUnload);
router.events.on('routeChangeStart', beforeRouteChange);
return () => {
window.removeEventListener('beforeunload', beforeUnload);
router.events.off('routeChangeStart', beforeRouteChange);
document.removeEventListener('paste', listener);
};
}, [loading, beforeUnload, beforeRouteChange]);
const handleChunkedFiles = async (expiresAt: Date, toChunkFiles: File[]) => {
for (let i = 0; i !== toChunkFiles.length; ++i) {
@@ -77,18 +98,6 @@ export default function File({ chunks: chunks_config }) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
// if last chunk send notif that it will take a while
if (j === chunks.length - 1) {
updateNotification({
id: 'upload-chunked',
title: 'Finalizing partial upload',
message: 'This may take a while...',
icon: <ClockIcon />,
color: 'yellow',
autoClose: false,
});
}
const body = new FormData();
body.append('file', chunks[j].blob);
@@ -108,32 +117,25 @@ export default function File({ chunks: chunks_config }) {
title: `Uploading chunk ${j + 1}/${chunks.length} Successful`,
message: '',
color: 'green',
icon: <UploadIcon />,
icon: <IconFileUpload size='1rem' />,
autoClose: false,
});
if (j === chunks.length - 1) {
updateNotification({
id: 'upload-chunked',
title: 'Upload Successful',
message: '',
title: 'Finalizing partial upload',
message:
'The upload has been offloaded, and will complete in the background. You can see processing files in the files tab.',
icon: <IconFileTime size='1rem' />,
color: 'green',
icon: <UploadIcon />,
autoClose: true,
});
showFilesModal(clipboard, modals, json.files);
invalidateFiles();
setFiles([]);
setProgress(100);
setTimeout(() => setProgress(0), 1000);
clipboard.copy(json.files[0]);
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
}
ready = true;
@@ -143,7 +145,7 @@ export default function File({ chunks: chunks_config }) {
title: `Uploading chunk ${j + 1}/${chunks.length} Failed`,
message: json.error,
color: 'red',
icon: <CrossIcon />,
icon: <IconFileX size='1rem' />,
autoClose: false,
});
ready = false;
@@ -237,7 +239,7 @@ export default function File({ chunks: chunks_config }) {
title: 'Upload Successful',
message: '',
color: 'green',
icon: <UploadIcon />,
icon: <IconFileUpload size='1rem' />,
});
showFilesModal(clipboard, modals, json.files);
setFiles([]);
@@ -260,7 +262,7 @@ export default function File({ chunks: chunks_config }) {
title: 'Upload Failed',
message: json.error,
color: 'red',
icon: <CrossIcon />,
icon: <IconFileX size='1rem' />,
});
}
setProgress(0);
@@ -320,7 +322,7 @@ export default function File({ chunks: chunks_config }) {
Clear Files
</Button>
<Button
leftIcon={<UploadIcon />}
leftIcon={<IconFileUpload size='1rem' />}
onClick={handleUpload}
disabled={files.length === 0 ? true : false}
>

View File

@@ -2,8 +2,8 @@ import { Alert, Button, Card, Container, Group, Select, Tabs, Title } from '@man
import { useClipboard } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { showNotification, updateNotification } from '@mantine/notifications';
import { IconCursorText, IconFileInfinity, IconFileUpload, IconPhoto } from '@tabler/icons-react';
import CodeInput from 'components/CodeInput';
import { ImageIcon, TypeIcon, UploadIcon } from 'components/icons';
import KaTeX from 'components/render/KaTeX';
import Markdown from 'components/render/Markdown';
import PrismCode from 'components/render/PrismCode';
@@ -83,10 +83,10 @@ export default function Text() {
<Tabs defaultValue='text' variant='pills'>
<Tabs.List>
<Tabs.Tab value='text' icon={<TypeIcon />}>
<Tabs.Tab value='text' icon={<IconCursorText size='1rem' />}>
Text
</Tabs.Tab>
<Tabs.Tab value='preview' icon={<ImageIcon />}>
<Tabs.Tab value='preview' icon={<IconPhoto size='1rem' />}>
Preview
</Tabs.Tab>
</Tabs.List>
@@ -125,7 +125,7 @@ export default function Text() {
onChange={setLang}
dropdownPosition='top'
data={Object.keys(exts).map((x) => ({ value: x, label: exts[x] }))}
icon={<TypeIcon />}
icon={<IconFileInfinity size='1rem' />}
searchable
/>
@@ -134,7 +134,7 @@ export default function Text() {
</Button>
<Button
leftIcon={<UploadIcon />}
leftIcon={<IconFileUpload size='1rem' />}
onClick={handleUpload}
disabled={value.trim().length === 0 ? true : false}
>

View File

@@ -1,7 +1,7 @@
import { ActionIcon, Box, Button, Group, Stack, Table, Title, Tooltip } from '@mantine/core';
import { ActionIcon, Group, Stack, Table, Title, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { CopyIcon, LinkIcon } from 'components/icons';
import Link from 'components/Link';
import { IconClipboardCopy, IconExternalLink } from '@tabler/icons-react';
import AnchorNext from 'components/AnchorNext';
export default function showFilesModal(clipboard, modals, files: string[]) {
const open = (idx: number) => window.open(files[idx], '_blank');
@@ -16,8 +16,8 @@ export default function showFilesModal(clipboard, modals, files: string[]) {
else
showNotification({
title: 'Copied to clipboard',
message: <Link href={files[idx]}>{files[idx]}</Link>,
icon: <CopyIcon />,
message: <AnchorNext href={files[idx]}>{files[idx]}</AnchorNext>,
icon: <IconClipboardCopy size='1rem' />,
});
};
@@ -30,17 +30,17 @@ export default function showFilesModal(clipboard, modals, files: string[]) {
{files.map((file, idx) => (
<Group key={idx} position='apart'>
<Group position='left'>
<Link href={file}>{file}</Link>
<AnchorNext href={file}>{file}</AnchorNext>
</Group>
<Group position='right'>
<Tooltip label='Open link in a new tab'>
<ActionIcon onClick={() => open(idx)} variant='filled' color='primary'>
<LinkIcon />
<IconExternalLink size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Copy link to clipboard'>
<ActionIcon onClick={() => copy(idx)} variant='filled' color='primary'>
<CopyIcon />
<IconClipboardCopy size='1rem' />
</ActionIcon>
</Tooltip>
</Group>

View File

@@ -10,8 +10,8 @@ import {
TextInput,
Title,
} from '@mantine/core';
import { ClockIcon, ImageIcon, KeyIcon, TypeIcon, UserIcon, GlobeIcon } from 'components/icons';
import React, { Dispatch, SetStateAction, useReducer, useState } from 'react';
import { IconAlarm, IconEye, IconFileInfo, IconKey, IconPhotoDown, IconWorld } from '@tabler/icons-react';
import React, { Dispatch, ReactNode, SetStateAction, useReducer, useState } from 'react';
export type UploadOptionsState = {
expires: string;
@@ -37,7 +37,11 @@ export function OptionsModal({
opened: boolean;
setOpened: Dispatch<SetStateAction<boolean>>;
state: UploadOptionsState;
setState: Dispatch<SetStateAction<any>>;
setState: Dispatch<
SetStateAction<{
[key in keyof UploadOptionsState]?: UploadOptionsState[key];
}>
>;
reset: () => void;
}) {
const [odState, setODState] = useReducer((state, newState) => ({ ...state, ...newState }), {
@@ -79,16 +83,16 @@ export function OptionsModal({
label='Max Views'
description='The maximum number of times this file can be viewed. Leave blank for unlimited views.'
value={state.maxViews}
onChange={(e) => setState({ maxViews: e })}
onChange={(e) => setState({ maxViews: e === '' ? undefined : e })}
min={0}
icon={<UserIcon />}
icon={<IconEye size='1rem' />}
/>
<Select
label='Expires'
description='The date and time this file will expire. Leave blank for never.'
value={state.expires}
onChange={(e) => setState({ expires: e })}
icon={<ClockIcon size={14} />}
icon={<IconAlarm size='1rem' />}
data={[
{ value: 'never', label: 'Never' },
{ value: '5min', label: '5 minutes' },
@@ -118,6 +122,11 @@ export function OptionsModal({
{ value: '6m', label: '6 months' },
{ value: '8m', label: '8 months' },
{ value: '1y', label: '1 year' },
{
value: null,
label: 'Need more freedom? Set an exact date and time through the API.',
disabled: true,
},
]}
/>
<Select
@@ -125,12 +134,18 @@ export function OptionsModal({
description='The compression level to use when uploading this file. Leave blank for default.'
value={state.compression}
onChange={(e) => setState({ compression: e })}
icon={<ImageIcon />}
icon={<IconPhotoDown size='1rem' />}
data={[
{ value: 'none', label: 'None' },
{ value: '25', label: 'Low (25%)' },
{ value: '50', label: 'Medium (50%)' },
{ value: '75', label: 'High (75%)' },
{ value: '100', label: 'Maximum (100%)' },
{
value: null,
label: 'Need more freedom? Set a custom compression level through the API.',
disabled: true,
},
]}
/>
<Select
@@ -138,13 +153,14 @@ export function OptionsModal({
description="The file name format to use when uploading this file. Leave blank for the server's default."
value={state.format}
onChange={(e) => setState({ format: e })}
icon={<TypeIcon />}
icon={<IconFileInfo size='1rem' />}
data={[
{ value: 'default', label: 'Default' },
{ value: 'RANDOM', label: 'Random' },
{ value: 'NAME', label: 'Original Name' },
{ value: 'DATE', label: 'Date (format configured by server)' },
{ value: 'UUID', label: 'UUID' },
{ value: 'random', label: 'Random' },
{ value: 'name', label: 'Original Name' },
{ value: 'date', label: 'Date (format configured by server)' },
{ value: 'uuid', label: 'UUID' },
{ value: 'gfycat', label: 'Gfycat' },
]}
/>
<PasswordInput
@@ -152,12 +168,12 @@ export function OptionsModal({
description='The password required to view this file. Leave blank for no password.'
value={state.password}
onChange={(e) => setState({ password: e.currentTarget.value })}
icon={<KeyIcon />}
icon={<IconKey size='1rem' />}
/>
<TextInput
label='Override Domain'
onChange={handleOD}
icon={<GlobeIcon />}
icon={<IconWorld size='1rem' />}
description={odState.description}
error={odState.error}
/>
@@ -194,7 +210,11 @@ export function OptionsModal({
);
}
export default function useUploadOptions(): [UploadOptionsState, Dispatch<SetStateAction<boolean>>, any] {
export default function useUploadOptions(): [
UploadOptionsState,
Dispatch<SetStateAction<boolean>>,
ReactNode
] {
const [state, setState] = useReducer((state, newState) => ({ ...state, ...newState }), {
expires: 'never',
password: '',

View File

@@ -1,59 +1,21 @@
import { ActionIcon, Card, Group, LoadingOverlay, Stack, Title, Tooltip } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { CopyIcon, CrossIcon, DeleteIcon, ExternalLinkIcon } from 'components/icons';
import TrashIcon from 'components/icons/TrashIcon';
import Link from 'components/Link';
import { ActionIcon, Card, Group, Stack, Title, Tooltip } from '@mantine/core';
import { IconClipboardCopy, IconExternalLink, IconTrash } from '@tabler/icons-react';
import AnchorNext from 'components/AnchorNext';
import MutedText from 'components/MutedText';
import { URLResponse, useURLDelete } from 'lib/queries/url';
import { URLResponse } from 'lib/queries/url';
import { relativeTime } from 'lib/utils/client';
export default function URLCard({ url }: { url: URLResponse }) {
const clipboard = useClipboard();
const urlDelete = useURLDelete();
const copyURL = (u) => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
else
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
};
const deleteURL = async (u) => {
urlDelete.mutate(u.id, {
onSuccess: () => {
showNotification({
title: 'Deleted URL',
message: '',
icon: <CrossIcon />,
color: 'green',
});
},
onError: (url: any) => {
showNotification({
title: 'Failed to delete URL',
message: url.error,
icon: <DeleteIcon />,
color: 'red',
});
},
});
};
export default function URLCard({
url,
copyURL,
deleteURL,
}: {
url: URLResponse;
copyURL: (u: URLResponse) => void;
deleteURL: (u: URLResponse) => void;
}) {
return (
<Card key={url.id} sx={{ maxWidth: '100%' }} shadow='sm'>
<LoadingOverlay visible={urlDelete.isLoading} />
<Group position='apart'>
<Group position='left'>
<Stack spacing={0}>
@@ -73,19 +35,19 @@ export default function URLCard({ url }: { url: URLResponse }) {
)}
<MutedText size='sm'>Views: {url.views}</MutedText>
<MutedText size='sm'>
URL: <Link href={url.destination}>{url.destination}</Link>
URL: <AnchorNext href={url.destination}>{url.destination}</AnchorNext>
</MutedText>
</Stack>
</Group>
<Stack>
<ActionIcon href={url.url} component='a' target='_blank'>
<ExternalLinkIcon />
<IconExternalLink size='1rem' />
</ActionIcon>
<ActionIcon aria-label='copy' onClick={() => copyURL(url)}>
<CopyIcon />
<IconClipboardCopy size='1rem' />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => deleteURL(url)}>
<TrashIcon />
<IconTrash size='1rem' />
</ActionIcon>
</Stack>
</Group>

View File

@@ -8,27 +8,71 @@ import {
NumberInput,
SimpleGrid,
Skeleton,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useClipboard } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { CrossIcon, LinkIcon, PlusIcon } from 'components/icons';
import {
IconClipboardCopy,
IconExternalLink,
IconGridDots,
IconLink,
IconLinkOff,
IconList,
} from '@tabler/icons-react';
import AnchorNext from 'components/AnchorNext';
import MutedText from 'components/MutedText';
import { useURLs } from 'lib/queries/url';
import { ApiError } from 'hooks/useFetch';
import { useURLDelete, useURLs } from 'lib/queries/url';
import { listViewUrlsSelector } from 'lib/recoil/settings';
import { userSelector } from 'lib/recoil/user';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil';
import URLCard from './URLCard';
export default function Urls() {
const user = useRecoilValue(userSelector);
const modals = useModals();
const clipboard = useClipboard();
const urls = useURLs();
const [createOpen, setCreateOpen] = useState(false);
const updateURLs = async () => urls.refetch();
const [listView, setListView] = useRecoilState(listViewUrlsSelector);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
columnAccessor: 'id',
direction: 'asc',
});
const [records, setRecords] = useState(urls.data);
useEffect(() => {
setRecords(urls.data);
}, [urls.data]);
useEffect(() => {
if (!records || records.length === 0) return;
const sortedRecords = [...records].sort((a, b) => {
if (sortStatus.direction === 'asc') {
return a[sortStatus.columnAccessor] > b[sortStatus.columnAccessor] ? 1 : -1;
}
return a[sortStatus.columnAccessor] < b[sortStatus.columnAccessor] ? 1 : -1;
});
setRecords(sortedRecords);
}, [sortStatus]);
const form = useForm({
initialValues: {
url: '',
@@ -37,6 +81,16 @@ export default function Urls() {
},
});
const copy = (url) => {
clipboard.copy(url);
showNotification({
title: 'Copied to clipboard',
message: <AnchorNext href={url}>{url}</AnchorNext>,
color: 'green',
icon: <IconClipboardCopy size='1rem' />,
});
};
const onSubmit = async (values) => {
const cleanURL = values.url.trim();
const cleanVanity = values.vanity.trim();
@@ -78,14 +132,31 @@ export default function Urls() {
title: 'Failed to create URL',
message: json.error,
color: 'red',
icon: <CrossIcon />,
icon: <IconLinkOff size='1rem' />,
});
} else {
showNotification({
title: 'URL shortened',
message: json.url,
color: 'green',
icon: <LinkIcon />,
modals.openModal({
title: <Title>Shortened URL!</Title>,
size: 'auto',
children: (
<Group position='apart'>
<Group position='left'>
<AnchorNext href={json.url}>{data.vanity ?? json.url}</AnchorNext>
</Group>
<Group position='right'>
<Tooltip label='Open link in a new tab'>
<ActionIcon onClick={() => window.open(json.url, '_blank')} variant='filled' color='primary'>
<IconExternalLink size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Copy link to clipboard'>
<ActionIcon onClick={() => copy(json.url)} variant='filled' color='primary'>
<IconClipboardCopy size='1rem' />
</ActionIcon>
</Tooltip>
</Group>
</Group>
),
});
}
@@ -96,13 +167,52 @@ export default function Urls() {
updateURLs();
}, []);
const copyURL = (u) => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
else
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <IconClipboardCopy size='1rem' />,
});
};
const urlDelete = useURLDelete();
const deleteURL = async (u) => {
urlDelete.mutate(u.id, {
onSuccess: () => {
showNotification({
title: 'Deleted URL',
message: '',
icon: <IconLink size='1rem' />,
color: 'green',
});
},
onError: (url: ApiError) => {
showNotification({
title: 'Failed to delete URL',
message: url.error,
icon: <IconLinkOff size='1rem' />,
color: 'red',
});
},
});
};
return (
<>
<Modal opened={createOpen} onClose={() => setCreateOpen(false)} title={<Title>Shorten URL</Title>}>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput id='url' label='URL' {...form.getInputProps('url')} />
<TextInput id='vanity' label='Vanity' {...form.getInputProps('vanity')} />
<NumberInput id='maxViews' label='Max Views' {...form.getInputProps('maxViews')} />
<NumberInput id='maxViews' label='Max Views' {...form.getInputProps('maxViews')} min={0} />
<Group position='right' mt='md'>
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
@@ -114,16 +224,21 @@ export default function Urls() {
<Group mb='md'>
<Title>URLs</Title>
<ActionIcon variant='filled' color='primary' onClick={() => setCreateOpen(true)}>
<PlusIcon />
<IconLink size='1rem' />
</ActionIcon>
<Tooltip label={listView ? 'Switch to grid view' : 'Switch to list view'}>
<ActionIcon variant='filled' color='primary' onClick={() => setListView(!listView)}>
{listView ? <IconList size='1rem' /> : <IconGridDots size='1rem' />}
</ActionIcon>
</Tooltip>
</Group>
{urls.data && urls.data.length === 0 && (
{!listView && urls.data && urls.data.length === 0 && (
<Card shadow='md'>
<Center>
<Group>
<div>
<LinkIcon size={48} />
<IconLink size={48} />
</div>
<div>
<Title>Nothing here</Title>
@@ -134,11 +249,107 @@ export default function Urls() {
</Card>
)}
<SimpleGrid cols={4} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{urls.isLoading || !urls.data
? [1, 2, 3, 4].map((x) => <Skeleton key={x} width='100%' height={80} radius='sm' />)
: urls.data.map((url) => <URLCard key={url.id} url={url} />)}
</SimpleGrid>
{listView ? (
<DataTable
withBorder
borderRadius='md'
highlightOnHover
verticalSpacing='sm'
columns={[
{ accessor: 'id', title: 'ID', sortable: true },
{
accessor: 'vanity',
title: 'Vanity',
sortable: true,
render: (url) => <Text>{url.vanity ?? ''}</Text>,
},
{
accessor: 'destination',
title: 'URL',
sortable: true,
render: (url) => (
<AnchorNext href={url.url} target='_blank'>
{url.destination}
</AnchorNext>
),
},
{
accessor: 'views',
sortable: true,
},
{
accessor: 'maxViews',
sortable: true,
},
{
accessor: 'actions',
textAlignment: 'right',
render: (url) => (
<Group spacing={4} position='right' noWrap>
<Tooltip label='Open link in a new tab'>
<ActionIcon
onClick={() => window.open(url.url, '_blank')}
variant='subtle'
color='primary'
>
<IconExternalLink size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Copy link to clipboard'>
<ActionIcon onClick={() => copyURL(url)} variant='subtle' color='primary'>
<IconClipboardCopy size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Delete URL'>
<ActionIcon onClick={() => deleteURL(url)} variant='subtle' color='red'>
<IconLinkOff size='1rem' />
</ActionIcon>
</Tooltip>
</Group>
),
},
]}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
records={records ?? []}
fetching={urls.isLoading}
loaderBackgroundBlur={5}
minHeight='calc(100vh - 200px)'
loaderVariant='dots'
rowContextMenu={{
shadow: 'xl',
borderRadius: 'md',
items: (url) => [
{
key: 'openLink',
title: 'Open link in a new tab',
icon: <IconExternalLink size='1rem' />,
onClick: () => window.open(url.url, '_blank'),
},
{
key: 'copyLink',
title: 'Copy link to clipboard',
icon: <IconClipboardCopy size='1rem' />,
onClick: () => copyURL(url),
},
{
key: 'deleteURL',
title: 'Delete URL',
icon: <IconLinkOff size='1rem' />,
onClick: () => deleteURL(url),
},
],
}}
/>
) : (
<SimpleGrid cols={4} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{urls.isLoading || !urls.data
? [1, 2, 3, 4].map((x) => <Skeleton key={x} width='100%' height={80} radius='sm' />)
: urls.data.map((url) => (
<URLCard key={url.id} url={url} deleteURL={deleteURL} copyURL={copyURL} />
))}
</SimpleGrid>
)}
</>
);
}

View File

@@ -1,7 +1,7 @@
import { Button, Group, Modal, Switch, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { showNotification } from '@mantine/notifications';
import { DeleteIcon, PlusIcon } from 'components/icons';
import { IconUserPlus, IconUserX } from '@tabler/icons-react';
import useFetch from 'hooks/useFetch';
export function CreateUserModal({ open, setOpen, updateUsers }) {
@@ -31,14 +31,14 @@ export function CreateUserModal({ open, setOpen, updateUsers }) {
showNotification({
title: 'Failed to create user',
message: res.error,
icon: <DeleteIcon />,
icon: <IconUserX size='1rem' />,
color: 'red',
});
} else {
showNotification({
title: 'Created user: ' + cleanUsername,
message: '',
icon: <PlusIcon />,
icon: <IconUserPlus size='1rem' />,
color: 'green',
});
}
@@ -55,7 +55,9 @@ export function CreateUserModal({ open, setOpen, updateUsers }) {
<Group position='right' mt='md'>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button type='submit'>Create</Button>
<Button type='submit' rightIcon={<IconUserPlus size='1rem' />}>
Create
</Button>
</Group>
</form>
</Modal>

View File

@@ -1,7 +1,7 @@
import { Button, Group, Modal, Switch, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { showNotification } from '@mantine/notifications';
import { DeleteIcon, PlusIcon } from 'components/icons';
import { IconUserCheck, IconUserExclamation } from '@tabler/icons-react';
import useFetch from 'hooks/useFetch';
export function EditUserModal({ open, setOpen, updateUsers, user }) {
@@ -36,14 +36,14 @@ export function EditUserModal({ open, setOpen, updateUsers, user }) {
showNotification({
title: 'Failed to edit user',
message: res.error,
icon: <DeleteIcon />,
icon: <IconUserExclamation size='1rem' />,
color: 'red',
});
} else {
showNotification({
title: 'Edited user: ' + cleanUsername,
message: '',
icon: <PlusIcon />,
icon: <IconUserCheck size='1rem' />,
color: 'green',
});
}
@@ -52,7 +52,11 @@ export function EditUserModal({ open, setOpen, updateUsers, user }) {
};
return (
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>Edit User {user?.username}</Title>}>
<Modal
opened={open}
onClose={() => setOpen(false)}
title={<Title>Edit &quot;{user?.username}&quot;</Title>}
>
{user && (
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput id='username' label='Username' {...form.getInputProps('username')} />

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