Compare commits

...

98 Commits

Author SHA1 Message Date
diced
a1f281d8b4 feat(v3.6.0): version 2022-10-31 20:02:55 -07:00
diced
d2f3999cf1 fix: expires/expired text change 2022-10-31 16:43:12 -07:00
TacticalCoderJay
87fc9f2fb9 feat: v3.6.0-rc4 (#207)
* feat: oauth reform for potential improvements

* fix: update avatars with new pfp

* fix: remove redundant include

* feat: v3.6.0-rc4

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

* fix: remove console log

Co-authored-by: dicedtomato <diced@users.noreply.github.com>
2022-10-30 21:42:39 -07:00
diced
8c9064fd93 fix: rework image serving 2022-10-29 22:43:42 -07:00
diced
561849ae5b feat: ability to link existing accounts to oauth 2022-10-29 20:02:54 -07:00
diced
0847802ce4 fix: remove console.log 2022-10-29 14:59:26 -07:00
diced
d5a8b3f1fb chore: update workflows node@18 2022-10-29 10:53:21 -07:00
diced
e6cebd8c46 fix: update node@18, fix views aggregation, force update stats 2022-10-29 10:52:35 -07:00
TacticalCoderJay
f2be036bac feat: issue template (#202)
* feat: new issue templates

* fix: unique ids

* fix: typo

* fix: tabbing

* Update bug.yml

* Update suggest.yml

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2022-10-29 09:56:39 -07:00
diced
f14448d40d fix: add id to version 2022-10-28 21:14:34 -07:00
diced
bf719808f2 fix: merge changes to release 2022-10-28 21:09:41 -07:00
diced
9dd82c91d7 fix: actions outputs maybe 2022-10-28 21:08:40 -07:00
diced
535f84064a fix: actions outputs 2022-10-28 21:05:44 -07:00
diced
0c0a55d766 fix: update actions/cache 2022-10-28 21:01:49 -07:00
diced
6e3ee29eb4 fix: upgrade checkout action 2022-10-28 21:01:02 -07:00
diced
6a7a5dc7a3 fix: migrate from save-state to env 2022-10-28 20:57:01 -07:00
diced
e78d2d79d0 feat: upgrade docker actions 2022-10-28 20:53:13 -07:00
diced
451027eaf3 feat: add cache to releawes 2022-10-28 20:44:43 -07:00
diced
e4491610fb feat: add trunk-(version) to tag & test cache 2022-10-28 20:41:39 -07:00
diced
f30e10f235 feat: try adding docker caching 2022-10-28 19:37:09 -07:00
diced
f9249b1380 feat: even more for URLs 2022-10-28 17:41:46 -07:00
diced
3df94526b0 feat: add more info to cards & relative time 2022-10-28 17:28:15 -07:00
diced
b30b7b1bd3 fix: #204 2022-10-28 16:47:16 -07:00
dicedtomato
a9defd67d6 feat: add screenshots to readme 2022-10-27 21:53:12 -07:00
diced
68d346e69d fix: stuff 2022-10-27 21:26:54 -07:00
diced
e2fd27cbba feat(3.6.0-rc3): version & bump deps 2022-10-27 21:09:14 -07:00
diced
4c0532006c fix: resolve multiple el in titles 2022-10-27 21:07:44 -07:00
diced
7ac574b230 fix: support .env file 2022-10-27 19:38:54 -07:00
diced
7eb855de8f feat: new file serving method & max views for files 2022-10-27 19:34:20 -07:00
diced
d5984f4141 chore: make things work on next 13 2022-10-26 18:20:56 -07:00
diced
b7c0c85639 chore: bump nextjs 2022-10-25 17:52:04 -07:00
diced
84ba166aea feat: file chunking for large uploads 2022-10-24 18:31:49 -07:00
diced
bd79858681 feat(3.6.0-rc2): version 2022-10-24 11:39:42 -07:00
diced
0f10fa3991 fix: uniform margins 2022-10-24 11:38:38 -07:00
diced
74b1799d21 feat: user registration without oauth 2022-10-24 11:28:06 -07:00
diced
4552643ff8 feat: more options for text uploads & password protect them 2022-10-24 11:10:32 -07:00
diced
d432b388f6 feat: preview text uploads 2022-10-24 10:57:13 -07:00
diced
a8475602c7 feat: add port config opt for s3 2022-10-23 12:33:04 -07:00
diced
f58d33af9e fix: recurse for entryPoints 2022-10-23 12:16:59 -07:00
diced
0150ea5e70 fix: spacing between main dashboard elements 2022-10-23 11:57:24 -07:00
diced
3bf43f1606 fix: maybe fix exports 2022-10-23 10:36:38 -07:00
diced
b8729a6ec7 fix: save imported files to datasource 2022-10-23 10:14:29 -07:00
diced
1f44aa7e85 fix: await on prisma.image.createMany 2022-10-23 10:09:48 -07:00
diced
2bd5352fc5 feat: import-dir script 2022-10-23 10:08:21 -07:00
diced
a90130e8bf feat(v3.6.0-rc1): small fixes 2022-10-22 23:42:52 -07:00
diced
642e8796f0 feat: oauth info in user dropdown 2022-10-22 16:10:31 -07:00
diced
615cbddc89 feat: ability to edit/delete users with master administrator 2022-10-22 14:50:53 -07:00
diced
4ef82bdff4 fix: prettier 2022-10-22 14:30:50 -07:00
diced
dafde04c2c fix: config validation for discord is null 2022-10-22 14:26:34 -07:00
diced
1be61b8d89 chore: update deps & tsx -> esbuild 2022-10-22 14:23:23 -07:00
diced
c3215c7425 fix: add oauth to readme 2022-10-19 19:49:25 -07:00
Jonathan
af0cd26ea0 feat: prettier run (#200)
* feat: prettier run

* fix: whatever that was

* chore: format more files

* chore: make format command better
2022-10-19 19:43:01 -07:00
Jonathan
cb7dacd089 perf: config validation improvements (#192)
* perf: improve config validation

* chore: remove extra space in package.json

* fix: actually update file

* fix: `datasource.local` not providing a default value

* fix: small oversight in readConfig & better error

Co-authored-by: diced <pranaco2@gmail.com>
2022-10-18 22:15:15 -07:00
TacticalCoderJay
8c04971094 fix: create -> save (edit user) (#199) 2022-10-18 20:19:40 -07:00
diced
3a4802f09a fix: checks for export & refresh button 2022-10-17 17:20:54 -07:00
TacticalCoderJay
d78db306c5 fix: use os.tmpdir instead of hardcoded /tmp (#198) 2022-10-17 17:05:38 -07:00
TacticalCoderJay
3f8790ece1 fix: only display exports that are your own (#197) 2022-10-17 16:48:01 -07:00
TacticalCoderJay
f9e6158144 hotfix: change text to adjusted limit (#196) 2022-10-17 07:59:33 -07:00
TacticalCoderJay
05de3fed15 feat: ability to create many invites (#194) (#183)
* feat: Create many invites added.

* Update src/pages/api/auth/invite.ts

Co-authored-by: Jonathan <axis@axis.moe>

* fix: Lowered limit.

Co-authored-by: Jonathan <axis@axis.moe>
2022-10-16 20:18:21 -07:00
TacticalCoderJay
38cba9cb39 fix: Follow proper linebreak style. (#191) 2022-10-16 20:10:52 -07:00
diced
a4af980e11 fix: password strength not showing up on invites (#186) 2022-10-16 14:32:27 -07:00
diced
940b844857 feat: admins can't edit/delete other admins 2022-10-16 14:12:44 -07:00
diced
41b766216e feat: naming & views on files #187 #181 2022-10-16 14:06:32 -07:00
Jonathan
402987baba fix(dashboard): error when fetching stats (#193)
Handles an edge case where stats.data is a 1 length array since there's no before data
2022-10-16 13:44:46 -07:00
Winter
3cb08c73d3 feat: add S3 SSL as an env variable (#188)
Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2022-10-15 12:33:37 -07:00
Derock
4cb92a7257 fix: NaN on stat card (#179)
* fix: NaN % change

* ref: format

Co-authored-by: Jonathan <axis@axis.moe>

Co-authored-by: Jonathan <axis@axis.moe>
Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2022-10-15 12:22:57 -07:00
diced
a095768eae ref: change look of oauth 2022-10-13 21:47:56 -07:00
diced
1a5925d7e8 fix: add minWidth to user dropdown 2022-10-08 21:28:36 -07:00
diced
9147847710 fix: make line graph tooltips work everywhere 2022-10-08 21:24:05 -07:00
diced
05fe8bcaca fix: remove debug logs 2022-10-08 21:07:55 -07:00
diced
b0c3c6f45a feat: oauth sign up 2022-10-08 17:58:56 -07:00
diced
0f641aa852 fix: make last icon flat 2022-10-02 16:01:33 -07:00
diced
2651bbe50c fix: change readme 2022-10-02 16:01:05 -07:00
diced
d31371eb6c feat: configurable invites & disable_media_preview config 2022-10-02 15:39:59 -07:00
diced
ec0e7e5ec7 feat: edit users (admin-only) 2022-10-01 14:04:18 -07:00
diced
feb75a8a42 fix(config): add env for UPLOADER_DATE_FORMAT 2022-10-01 13:34:41 -07:00
diced
d4369d2503 refactor: redux -> recoil 2022-10-01 11:09:07 -07:00
diced
d236589644 fix: tidy up stuff 2022-10-01 11:09:07 -07:00
diced
8044b7f623 refactor: move clientUtils into utils/ 2022-10-01 11:09:07 -07:00
dicedtomato
9f0697dd34 feat: add contributing.md 2022-09-30 16:16:08 -07:00
diced
78a6f3122d fix: escape . in regexes 2022-09-28 20:45:50 -07:00
diced
b460da74dd refactor: change bool() -> boolean() 2022-09-28 20:30:24 -07:00
Wolfy0615
75a8bb7962 fix: change outdated url (#172)
* Fix outdated url

Updated to resolve merge conflicts

* Update index.tsx

Fix #2
2022-09-28 19:50:01 -07:00
diced
9ac876e30a feat: ability to view non-media views 2022-09-24 14:25:34 -07:00
diced
26cb4ea034 fix: placeholder and red buttons 2022-09-24 10:43:23 -07:00
diced
0d65ee1a32 feat: flameshot generator 2022-09-24 10:41:03 -07:00
Erik Bender
4a753376b7 feat: add password field in file upload ui (#169) 2022-09-24 10:14:36 -07:00
Derock
dc926e9f5a feat: overhaul a lot of stuff (#171)
* feat: ssr for /code/[id], fix: syntax highlighting

* feat: cache responses

* ref: eslint

* wip

* Create .gitattributes

* wip again

* redesign dashboard

* ref: use react-query for url
ref: break into components
feat: loading animation for delete
feat: no url image

* feat: use react-query mutation for files

* ref: update sizing on code input

* chore(deps): update mantine

* feat: overhaul stats page

* fix: incorrectly calculating stat % change

* fix: use latest data in stats per day

* feat: add validation on stats amount query string

* refactor: clean up imports & code

* fix: remove prettier (fixes eslint)

* ref: run eslint autofix

* ref: more eslint

* ref: replace undraw on homepage with react-feather

* refactor: remove tailwind & add responsiveness to stuff

* fix: colors on file placeholder

* fix: make actions work

* feat: new sharex configuration generator

Co-authored-by: diced <pranaco2@gmail.com>
2022-09-23 18:19:27 -07:00
diced
722372c7f6 fix: #173 2022-09-11 12:08:38 -07:00
diced
4589c6ee0a fix: #166 2022-09-05 15:06:07 -07:00
diced
67ff93e640 fix: remove useless webpack cofnig 2022-08-26 20:38:31 -07:00
diced
bd055d704b fix: domain duplication 2022-08-26 20:35:25 -07:00
diced
2e8bee931c feat: add a debug read-config script 2022-08-26 20:06:57 -07:00
diced
a454a4f4a8 feat: external links & bug fixes 2022-08-26 20:04:25 -07:00
diced
45541a3cdd feat: add version to appshell 2022-08-24 20:37:57 -07:00
diced
1d42d922bd feat: discord webhook notifs 2022-08-23 09:38:29 -07:00
diced
4f631fbd0e feat: more ways to expire 2022-08-21 22:24:56 -07:00
diced
e911db4c1a fix: image table on dashboard 2022-08-17 15:01:23 -07:00
205 changed files with 10503 additions and 14019 deletions

View File

@@ -23,6 +23,7 @@ DATASOURCE_S3_BUCKET=bucket
DATASOURCE_S3_ENDPOINT=s3.amazonaws.com
DATASOURCE_S3_REGION=us-west-2
DATASOURCE_S3_FORCE_S3_PATH=false
DATASOURCE_S3_USE_SSL=false
# or you can use swift
DATASOURCE_TYPE=swift

View File

@@ -1,36 +1,18 @@
{
"extends": [
"next",
"next/core-web-vitals"
],
"extends": ["next", "next/core-web-vitals", "plugin:prettier/recommended"],
"rules": {
"indent": [
"error",
2,
{
"SwitchCase": 1
}
],
"linebreak-style": [
"error",
"unix"
],
"linebreak-style": ["error", "unix"],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
],
"comma-dangle": [
"error",
"always-multiline"
],
"jsx-quotes": [
"error",
"prefer-single"
"single",
{
"avoidEscape": true
}
],
"semi": ["error", "always"],
"comma-dangle": ["error", "always-multiline"],
"jsx-quotes": ["error", "prefer-single"],
"indent": "off",
"react/prop-types": "off",
"react-hooks/rules-of-hooks": "off",
"react-hooks/exhaustive-deps": "off",
@@ -48,4 +30,4 @@
"jsx-a11y/alt-text": "off",
"react/display-name": "off"
}
}
}

14
.gitattributes vendored Normal file
View File

@@ -0,0 +1,14 @@
# Set the default behavior, in case people don't have core.autocrlf set.
* text eol=lf
# Explicitly declare text files you want to always be normalized and converted
# to native line endings on checkout.
*.c text
*.h text
# Declare files that will always have CRLF line endings on checkout.
*.sln text eol=crlf
# Denote all files that are truly binary and should not be modified.
*.png binary
*.jpg binary

45
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: Bug
description: File a bug report
title: 'Bug: '
labels: ['bug']
body:
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Provide steps to reproduce the bug, and some context.
value: 'A bug happened!'
validations:
required: true
- type: dropdown
id: version
attributes:
label: Version
description: What version of Zipline are you using?
options:
- upstream
- latest
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: What browser(s) are you seeing the problem on?
multiple: true
options:
- Firefox
- Chrome
- Safari
- Microsoft Edge
- type: textarea
id: zipline-logs
attributes:
label: Zipline Logs
description: Please copy and paste any relevant log output.
render: shell
- type: textarea
id: browser-logs
attributes:
label: Browser Logs
description: Please copy and paste any relevant log output.
render: shell

12
.github/ISSUE_TEMPLATE/suggest.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
name: Suggestion
description: Suggest a feature to be added
title: 'Suggestion: '
labels: ['suggestion']
body:
- type: textarea
id: suggest
attributes:
label: Suggestion
description: Be as descriptive as possible!
placeholder: What do you want in Zipline?
value: A suggestion

View File

@@ -11,16 +11,22 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '16.x'
node-version: '18.x'
- name: 'Restore dependency cache'
id: cache-restore
uses: actions/cache@v2
uses: actions/cache@v3
with:
path: node_modules
key: ${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
path: |
node_modules
${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
restore-keys: |
${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}-
- name: Install dependencies
if: steps.cache-restore.outputs.cache-hit != 'true'

View File

@@ -18,33 +18,34 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Get version
uses: sergeysova/jq-action@v2
id: version
with:
cmd: 'jq .version package.json -r'
run: |
echo "zipline_version=$(jq .version package.json -r)" >> $GITHUB_OUTPUT
- name: Setup QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
- name: Login to Github Packages
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
push: true
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/diced/zipline:latest
ghcr.io/diced/zipline:${{ steps.version.outputs.value }}
ghcr.io/diced/zipline:${{ steps.version.outputs.zipline_version }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -17,26 +17,34 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Get version
id: version
run: |
echo "zipline_version=$(jq .version package.json -r)" >> $GITHUB_OUTPUT
- name: Setup QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
- name: Login to Github Packages
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
push: true
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/diced/zipline:trunk
ghcr.io/diced/zipline:trunk-${{ steps.version.outputs.zipline_version }}
cache-from: type=gha
cache-to: type=gha,mode=max

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
18.12.0

5
.prettierrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 110
}

5
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"editor.tabSize": 2,
"files.eol": "\n",
"typescript.tsdk": "node_modules/typescript/lib"
}

File diff suppressed because one or more lines are too long

801
.yarn/releases/yarn-3.2.4.cjs vendored Executable file

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,9 @@
checksumBehavior: update
nodeLinker: node-modules
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: "@yarnpkg/plugin-interactive-tools"
yarnPath: .yarn/releases/yarn-3.2.1.cjs
yarnPath: .yarn/releases/yarn-3.2.4.cjs

23
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,23 @@
# Contributing
## Bug reports
Create an issue on GitHub, please include the following (if one of them is not applicable to the issue then it's not needed):
- The steps to reproduce the bug
- Logs of Zipline
- The version of Zipline
- Your OS & Browser including server OS
- What you were expecting to see
## Feature requests
Create an issue on GitHub, please include the following:
- Breif explanation of the feature in the title (very breif please)
- How it would work (detailed, but optional)
## Pull Requests (contributions to the codebase)
Create a pull request on GitHub. If your PR does not pass the action checks, then please fix the errors. If your PR was submitted before a release, and I have pushed a new release, please make sure to update your PR to reflect any changes, usually this is handled by GitHub.
Please make sure your code also reflects the style of the rest of the code.

View File

@@ -1,16 +1,15 @@
FROM ghcr.io/diced/prisma-binaries:4.1.x as prisma
FROM ghcr.io/diced/prisma-binaries:4.5.x as prisma
FROM alpine:3.16 AS deps
FROM node:alpine3.16 AS deps
RUN mkdir -p /prisma-engines
WORKDIR /build
COPY .yarn .yarn
COPY package.json yarn.lock .yarnrc.yml ./
RUN apk add --no-cache nodejs yarn
RUN yarn install --immutable
FROM alpine:3.16 AS builder
FROM node:alpine3.16 AS builder
WORKDIR /build
COPY --from=prisma /prisma-engines /prisma-engines
@@ -21,7 +20,7 @@ ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
PRISMA_CLIENT_ENGINE_TYPE=binary
RUN apk add --no-cache nodejs yarn openssl openssl-dev
RUN apk add --no-cache openssl openssl-dev
COPY --from=deps /build/node_modules ./node_modules
COPY src ./src
@@ -34,7 +33,7 @@ ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
FROM alpine:3.16 AS runner
FROM node:alpine3.16 AS runner
WORKDIR /zipline
COPY --from=prisma /prisma-engines /prisma-engines
@@ -45,7 +44,7 @@ ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
PRISMA_CLIENT_ENGINE_TYPE=binary
RUN apk add --no-cache nodejs yarn openssl openssl-dev
RUN apk add --no-cache openssl openssl-dev
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
@@ -55,9 +54,10 @@ COPY --from=builder /build/node_modules ./node_modules
COPY --from=builder /build/next.config.js ./next.config.js
COPY --from=builder /build/src ./src
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/prisma ./prisma
COPY --from=builder /build/tsconfig.json ./tsconfig.json
COPY --from=builder /build/package.json ./package.json
COPY --from=builder /build/mimes.json ./mimes.json
CMD ["node_modules/.bin/tsx", "src/server"]
CMD ["node", "--enable-source-maps", "dist/server"]

View File

@@ -1,17 +1,21 @@
<div align="center">
<img src="https://raw.githubusercontent.com/diced/zipline/trunk/public/zipline_small.png"/>
Zipline is a ShareX/file upload server that is easy to use, packed with features and can be setup in one command!
A ShareX/file upload server that is easy to use, packed with features, and with an easy setup!
![Build](https://img.shields.io/github/workflow/status/diced/zipline/CD:%20Push%20Docker%20Images?logo=github&style=flat-square)
![Stars](https://img.shields.io/github/stars/diced/zipline?logo=github&style=flat-square)
![Version](https://img.shields.io/github/package-json/v/diced/zipline?logo=git&logoColor=white&style=flat-square)
![GitHub last commit (branch)](https://img.shields.io/github/last-commit/diced/zipline/trunk?logo=git&logoColor=white&style=flat-square)
[![Discord](https://img.shields.io/discord/729771078196527176?color=%23777ed3&label=discord&logo=discord&logoColor=white&style=flat-square)](https://discord.gg/EAhCRfGxCF)
![Stars](https://img.shields.io/github/stars/diced/zipline?logo=github&style=flat)
![Version](https://img.shields.io/github/package-json/v/diced/zipline?logo=git&logoColor=white&style=flat)
![GitHub last commit (branch)](https://img.shields.io/github/last-commit/diced/zipline/trunk?logo=git&logoColor=white&style=flat)
[![Discord](https://img.shields.io/discord/729771078196527176?color=%23777ed3&label=discord&logo=discord&logoColor=white&style=flat)](https://discord.gg/EAhCRfGxCF)
![Build](https://img.shields.io/github/workflow/status/diced/zipline/Build?logo=github&style=flat)
[![Docker Image (trunk)](https://img.shields.io/github/workflow/status/diced/zipline/Push%20Docker%20Images?label=Docker%20%28trunk%29&logo=github&style=flat)](https://github.com/diced/zipline/pkgs/container/zipline/?tag=trunk)
[![Docker Image (release)](https://img.shields.io/github/workflow/status/diced/zipline/Push%20Release%20Docker%20Images?label=Docker%20%28release%29&logo=github&style=flat)](https://github.com/diced/zipline/pkgs/container/zipline/?tag=latest)
</div>
## Features
- Configurable
- Fast
- Built with Next.js & React
@@ -25,11 +29,27 @@
- Discord embeds (OG metadata)
- Gallery viewer, and multiple file format support
- Code highlighting
- Fully customizable Discord webhook notifications
- OAuth2 registration (Discord and GitHub)
- 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`)
<details>
<summary><h2>Screenshots (click)</h2></summary>
View full album at [imgur](https://imgur.com/a/GzyowZ7)
![Login Page](https://i.imgur.com/14Er7qt.png)
![Dashboard](https://i.imgur.com/3JK5bp6.png)
![Files Page](https://i.imgur.com/grIaDs8.png)
</details>
# Usage
## Install & run with Docker
This section requires [Docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/).
```shell
@@ -40,11 +60,14 @@ docker-compose up -d
```
### After installing
After installing, please edit the `docker-compose.yml` file and find the line that says `SECRET=changethis` and replace `changethis` with a random string.
Ways you could generate the string could be from a password managers generator, or you could just slam your keyboard and hope for the best.
## Building & running from source
This section requires [nodejs](https://nodejs.org), [yarn](https://yarnpkg.com/) or [npm](https://npmjs.com).
```shell
git clone https://github.com/diced/zipline
cd zipline
@@ -58,6 +81,7 @@ yarn start
```
# NGINX Proxy
This section requires [NGINX](https://nginx.org/).
```nginx
@@ -76,16 +100,20 @@ server {
```
# Website
The default port is `3000`, once you have accessed it you can see a login screen. The default credentials are "administrator" and "password". Once you login please immediately change the details to something more secure. You can do this by clicking on the top right corner where it says "administrator" with a gear icon and clicking Manage Account.
# ShareX (Windows)
This section requires [ShareX](https://www.getsharex.com/).
After navigating to Zipline, click on the top right corner where it says your username and click Manage Account. Scroll down to see "ShareX Config", select the one you would prefer using. After this you can import the .sxcu into sharex. [More information here](https://zipl.vercel.app/docs/uploaders/sharex)
After navigating to Zipline, click on the top right corner where it says your username and click Manage Account. Scroll down to see "ShareX Config", select the one you would prefer using. After this you can import the .sxcu into sharex. [More information here](https://zipl.vercel.app/docs/guides/uploaders/sharex)
# Flameshot (Linux)
This section requires [Flameshot](https://www.flameshot.org/), [jq](https://stedolan.github.io/jq/), and [xsel](https://github.com/kfish/xsel).
You can either use the script below, or generate one directly from Zipline (just like how you can generate a ShareX config).
To upload files using flameshot we will use a script. Replace $TOKEN and $HOST with your own values, you probably know how to do this if you use linux.
```shell
@@ -98,17 +126,22 @@ curl -H "Content-Type: multipart/form-data" -H "authorization: $TOKEN" -F file=@
# Contributing
## Bug reports
Create an issue on GitHub, please include the following (if one of them is not applicable to the issue then it's not needed):
* The steps to reproduce the bug
* Logs of Zipline
* The version of Zipline
* Your OS & Browser including server OS
* What you were expecting to see
- The steps to reproduce the bug
- Logs of Zipline
- The version of Zipline
- Your OS & Browser including server OS
- What you were expecting to see
## Feature requests
Create an issue on GitHub, please include the following:
* Breif explanation of the feature in the title (very breif please)
* How it would work (detailed, but optional)
- Breif explanation of the feature in the title (very breif please)
- How it would work (detailed, but optional)
## Pull Requests (contributions to the codebase)
Create a pull request on GitHub. If your PR does not pass the action checks, then please fix the errors. If your PR was submitted before a release, and I have pushed a new release, please make sure to update your PR to reflect any changes, usually this is handled by GitHub.
Create a pull request on GitHub. If your PR does not pass the action checks, then please fix the errors. If your PR was submitted before a release, and I have pushed a new release, please make sure to update your PR to reflect any changes, usually this is handled by GitHub.

View File

@@ -4,9 +4,10 @@
| Version | Supported |
| ------- | ------------------ |
| 3.4.4 | :white_check_mark: |
| 3.4.8 | :white_check_mark: |
| < 3 | :x: |
| < 2 | :x: |
## Reporting a Vulnerability
Report a Vulnerability by issuing a bug report, with exact details with how the vulnerability happened, what "exploits" can happen, and possible fixes (optional). Vulnerability reports are treated with high priority and will be resolved most of the time quickly.

View File

@@ -22,13 +22,8 @@ services:
ports:
- '3000:3000'
restart: unless-stopped
environment:
- CORE_HTTPS=false
- CORE_SECRET=changethislol
- CORE_HOST=0.0.0.0
- CORE_PORT=3000
- CORE_DATABASE_URL=postgres://postgres:postgres@postgres/postgres
- CORE_LOGGER=true
env_file:
- .env.local
volumes:
- '$PWD/uploads:/zipline/uploads'
- '$PWD/public:/zipline/public'
@@ -36,4 +31,4 @@ services:
- 'postgres'
volumes:
pg_data:
pg_data:

View File

@@ -34,4 +34,4 @@ services:
- 'postgres'
volumes:
pg_data:
pg_data:

View File

@@ -1,43 +1,23 @@
const esbuild = require('esbuild');
const { existsSync } = require('fs');
const { rm } = require('fs/promises');
const { recursiveReadDir } = require('next/dist/lib/recursive-readdir');
(async () => {
const watch = process.argv[2] === '--watch';
if (existsSync('./dist')) {
await rm('./dist', { recursive: true });
}
const entryPoints = await recursiveReadDir('./src', /.*\.(ts)$/, /(themes|queries|pages)/);
await esbuild.build({
tsconfig: 'tsconfig.json',
outdir: 'dist',
bundle: false,
platform: 'node',
treeShaking: true,
entryPoints: [
'src/server/index.ts',
'src/server/util.ts',
'src/lib/logger.ts',
'src/lib/config.ts',
'src/lib/mimes.ts',
'src/lib/exts.ts',
'src/lib/config/Config.ts',
'src/lib/config/readConfig.ts',
'src/lib/config/validateConfig.ts',
'src/lib/datasources/Datasource.ts',
'src/lib/datasources/index.ts',
'src/lib/datasources/Local.ts',
'src/lib/datasources/S3.ts',
'src/lib/datasources/Swift.ts',
'src/lib/datasource.ts',
],
entryPoints,
format: 'cjs',
resolveExtensions: ['.ts', '.js'],
write: true,
watch,
incremental: watch,
sourcemap: true,
minify: false,
});
})();
})();

9680
mimes.json

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,16 @@ module.exports = {
},
];
},
images: {
domains: [
// For sharex icon in manage user
'getsharex.com',
// For flameshot icon, and maybe in the future other stuff from github
'raw.githubusercontent.com',
// Google Icon
'madeby.google.com',
],
},
poweredByHeader: false,
reactStrictMode: true,
};
};

View File

@@ -1,75 +1,86 @@
{
"name": "zipline",
"version": "3.5.0",
"version": "3.6.0",
"license": "MIT",
"scripts": {
"dev": "REACT_EDITOR=code NODE_ENV=development tsx src/server",
"build": "npm-run-all build:schema build:next",
"dev": "npm-run-all build:server dev:run",
"dev:run": "cross-env REACT_EDITOR=code NODE_ENV=development RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED=false node --enable-source-maps dist/server",
"build": "npm-run-all build:server build:schema build:next",
"build-ci": "cross-env ZIPLINE_DOCKER_BUILD=1 npm-run-all build:server build:schema build:next",
"build:server": "node esbuild.config.js",
"build:next": "next build",
"build:schema": "prisma generate --schema=prisma/schema.prisma",
"format": "prettier --write ./src/**/*.{ts,tsx} ./*.{md,js,json,yml}",
"migrate:dev": "prisma migrate dev --create-only",
"start": "tsx src/server",
"start": "node dist/server",
"lint": "next lint",
"docker:run": "docker-compose up -d",
"docker:down": "docker-compose down",
"docker:build-dev": "docker-compose --file docker-compose.dev.yml up --build"
"docker:build-dev": "docker-compose --file docker-compose.dev.yml up --build",
"scripts:read-config": "npm-run-all build:server && node dist/scripts/read-config",
"scripts:import-dir": "npm-run-all build:server && node dist/scripts/import-dir"
},
"dependencies": {
"@emotion/react": "^11.9.3",
"@emotion/server": "^11.4.0",
"@iarna/toml": "2.2.5",
"@mantine/core": "^5.0.0",
"@mantine/dropzone": "^5.0.0",
"@mantine/form": "^5.0.0",
"@mantine/hooks": "^5.0.0",
"@mantine/modals": "^5.0.0",
"@mantine/next": "^5.0.0",
"@mantine/notifications": "^5.0.0",
"@mantine/nprogress": "^5.0.0",
"@mantine/prism": "^5.0.0",
"@prisma/client": "^4.1.0",
"@prisma/internals": "^4.1.0",
"@prisma/migrate": "^4.1.0",
"@reduxjs/toolkit": "^1.8.2",
"argon2": "^0.28.5",
"@dicedtomato/mantine-data-grid": "0.0.23",
"@emotion/react": "^11.10.5",
"@emotion/server": "^11.10.0",
"@mantine/core": "^5.6.3",
"@mantine/dropzone": "^5.6.3",
"@mantine/form": "^5.6.3",
"@mantine/hooks": "^5.6.3",
"@mantine/modals": "^5.6.3",
"@mantine/next": "^5.6.3",
"@mantine/notifications": "^5.6.3",
"@mantine/nprogress": "^5.6.3",
"@mantine/prism": "^5.6.3",
"@prisma/client": "^4.5.0",
"@prisma/internals": "^4.5.0",
"@prisma/migrate": "^4.5.0",
"@sapphire/shapeshift": "^3.7.0",
"@tanstack/react-query": "^4.13.0",
"argon2": "^0.30.1",
"chart.js": "^3.9.1",
"chartjs-plugin-datalabels": "^2.1.0",
"color-hash": "^2.0.1",
"colorette": "^2.0.19",
"cookie": "^0.5.0",
"dotenv": "^16.0.1",
"dotenv-expand": "^8.0.3",
"fecha": "^4.2.3",
"fflate": "^0.7.3",
"find-my-way": "^6.3.0",
"minio": "^7.0.28",
"dayjs": "^1.11.6",
"dotenv": "^16.0.3",
"dotenv-expand": "^9.0.0",
"fflate": "^0.7.4",
"find-my-way": "^7.3.1",
"minio": "^7.0.32",
"ms": "canary",
"multer": "^1.4.5-lts.1",
"next": "^12.1.6",
"prisma": "^4.1.0",
"next": "^13.0.0",
"prisma": "^4.5.0",
"react": "^18.2.0",
"react-chartjs-2": "^4.3.1",
"react-dom": "^18.2.0",
"react-feather": "^2.0.10",
"react-redux": "^8.0.2",
"react-table": "^7.8.0",
"redux": "^4.2.0",
"sharp": "^0.30.7",
"yup": "^0.32.11"
"recoil": "^0.7.6",
"sharp": "^0.31.1"
},
"devDependencies": {
"@types/cookie": "^0.5.1",
"@types/minio": "^7.0.13",
"@types/minio": "^7.0.14",
"@types/multer": "^1.4.7",
"@types/node": "^15.12.2",
"@types/sharp": "^0.30.5",
"babel-plugin-import": "^1.13.5",
"esbuild": "^0.14.44",
"eslint": "^7.32.0",
"eslint-config-next": "12.1.6",
"@types/node": "^18.11.7",
"@types/react": "^18.0.24",
"@types/sharp": "^0.31.0",
"cross-env": "^7.0.3",
"esbuild": "^0.15.12",
"eslint": "^8.26.0",
"eslint-config-next": "^13.0.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"npm-run-all": "^4.1.5",
"ts-node": "^10.8.1",
"tsx": "^3.8.0",
"typescript": "^4.7.3"
"prettier": "^2.7.1",
"typescript": "^4.8.4"
},
"repository": {
"type": "git",
"url": "https://github.com/diced/zipline.git"
},
"packageManager": "yarn@3.2.1"
}
"packageManager": "yarn@3.2.4"
}

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "oauth" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "oauthProvider" TEXT,
ALTER COLUMN "password" DROP NOT NULL;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "oauthAccessToken" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "superAdmin" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Image" ADD COLUMN "maxViews" INTEGER;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Url" ADD COLUMN "maxViews" INTEGER;

View File

@@ -0,0 +1,31 @@
/*
Warnings:
- You are about to drop the column `oauth` on the `User` table. All the data in the column will be lost.
- You are about to drop the column `oauthAccessToken` on the `User` table. All the data in the column will be lost.
- You are about to drop the column `oauthProvider` on the `User` table. All the data in the column will be lost.
*/
-- CreateEnum
CREATE TYPE "OauthProviders" AS ENUM ('DISCORD', 'GITHUB');
-- AlterTable
ALTER TABLE "User" DROP COLUMN "oauth",
DROP COLUMN "oauthAccessToken",
DROP COLUMN "oauthProvider";
-- CreateTable
CREATE TABLE "OAuth" (
"id" SERIAL NOT NULL,
"provider" "OauthProviders" NOT NULL,
"userId" INTEGER NOT NULL,
"token" TEXT NOT NULL,
CONSTRAINT "OAuth_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "OAuth_provider_key" ON "OAuth"("provider");
-- AddForeignKey
ALTER TABLE "OAuth" ADD CONSTRAINT "OAuth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,5 @@
-- DropIndex
DROP INDEX "OAuth_provider_key";
-- AlterTable
ALTER TABLE "OAuth" ADD COLUMN "refresh" TEXT;

View File

@@ -0,0 +1,8 @@
-- DropForeignKey
ALTER TABLE "Image" DROP CONSTRAINT "Image_userId_fkey";
-- AlterTable
ALTER TABLE "Image" ALTER COLUMN "userId" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "Image" ADD CONSTRAINT "Image_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "OauthProviders" ADD VALUE 'GOOGLE';

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `username` to the `OAuth` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "OAuth" ADD COLUMN "username" TEXT NOT NULL;

View File

@@ -10,16 +10,18 @@ generator client {
model User {
id Int @id @default(autoincrement())
username String
password String
password String?
avatar String?
token String
administrator Boolean @default(false)
superAdmin Boolean @default(false)
systemTheme String @default("system")
embedTitle String?
embedColor String @default("#2f3136")
embedSiteName String? @default("{image.file} • {user.name}")
ratelimit DateTime?
domains String[]
oauth OAuth[]
images Image[]
urls Url[]
Invite Invite[]
@@ -38,14 +40,15 @@ model Image {
mimetype String @default("image/png")
created_at DateTime @default(now())
expires_at DateTime?
maxViews Int?
views Int @default(0)
favorite Boolean @default(false)
embed Boolean @default(false)
password String?
invisible InvisibleImage?
format ImageFormat @default(RANDOM)
user User @relation(fields: [userId], references: [id])
userId Int
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId Int?
}
model InvisibleImage {
@@ -60,6 +63,7 @@ model Url {
destination String
vanity String?
created_at DateTime @default(now())
maxViews Int?
views Int @default(0)
invisible InvisibleUrl?
user User @relation(fields: [userId], references: [id])
@@ -80,12 +84,27 @@ model Stats {
}
model Invite {
id Int @id @default(autoincrement())
code String @unique
created_at DateTime @default(now())
expires_at DateTime?
used Boolean @default(false)
createdBy User @relation(fields: [createdById], references: [id])
id Int @id @default(autoincrement())
code String @unique
created_at DateTime @default(now())
expires_at DateTime?
used Boolean @default(false)
createdBy User @relation(fields: [createdById], references: [id])
createdById Int
}
model OAuth {
id Int @id @default(autoincrement())
provider OauthProviders
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
username String
token String
refresh String?
}
enum OauthProviders {
DISCORD
GITHUB
GOOGLE
}

View File

@@ -1,31 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { hashPassword, createToken } from '../src/lib/util';
const prisma = new PrismaClient();
async function main() {
const user = await prisma.user.create({
data: {
username: 'administrator',
password: await hashPassword('password'),
token: createToken(),
administrator: true,
},
});
console.log(`
When logging into Zipline for the first time, use these credentials:
Username: "${user.username}"
Password: "password"
`);
}
main()
.catch(e => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -1,11 +1,10 @@
import { Card as MCard, Title } from '@mantine/core';
export default function Card({ name, children, ...other }) {
return (
<MCard p='md' shadow='sm' {...other}>
<Title order={2}>{name}</Title>
{name && <Title order={2}>{name}</Title>}
{children}
</MCard>
);
}
}

View File

@@ -4,18 +4,12 @@ const useStyles = createStyles((theme, { size }: { size: MantineSize }) => ({
input: {
fontFamily: 'monospace',
fontSize: theme.fn.size({ size, sizes: theme.fontSizes }) - 2,
height: '100vh',
height: '80vh',
},
}));
export default function CodeInput({ ...props }) {
const { classes } = useStyles({ size: 'md' }, { name: 'CodeInput' });
return (
<Textarea
classNames={{ input: classes.input }}
autoComplete='nope'
{...props}
/>
);
return <Textarea classNames={{ input: classes.input }} autoComplete='nope' {...props} />;
}

View File

@@ -1,15 +1,38 @@
import { Button, Card, Group, Modal, Stack, Text, Title, useMantineTheme } from '@mantine/core';
import { Button, Card, Group, LoadingOverlay, Modal, Stack, Text, Title, Tooltip } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import useFetch from 'hooks/useFetch';
import { relativeTime } from 'lib/utils/client';
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
import { useState } from 'react';
import Type from './Type';
import { CalendarIcon, ClockIcon, CopyIcon, CrossIcon, DeleteIcon, FileIcon, HashIcon, ImageIcon, StarIcon } from './icons';
import {
CalendarIcon,
ClockIcon,
CopyIcon,
CrossIcon,
DeleteIcon,
ExternalLinkIcon,
FileIcon,
HashIcon,
ImageIcon,
StarIcon,
EyeIcon,
} from './icons';
import MutedText from './MutedText';
import { relativeTime } from 'lib/clientUtils';
import Type from './Type';
import Link from './Link';
export function FileMeta({ Icon, title, subtitle }) {
return (
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}>
@@ -20,30 +43,38 @@ export function FileMeta({ Icon, title, subtitle }) {
);
}
export default function File({ image, updateImages }) {
export default function File({ image, updateImages, disableMediaPreview }) {
const [open, setOpen] = useState(false);
const deleteFile = useFileDelete();
const favoriteFile = useFileFavorite();
const clipboard = useClipboard();
const handleDelete = async () => {
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
if (!res.error) {
updateImages(true);
showNotification({
title: 'File Deleted',
message: '',
color: 'green',
icon: <DeleteIcon />,
});
} else {
showNotification({
title: 'Failed to delete file',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
}
const loading = deleteFile.isLoading || favoriteFile.isLoading;
setOpen(false);
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 = () => {
@@ -57,60 +88,108 @@ export default function File({ image, updateImages }) {
};
const handleFavorite = async () => {
const data = await useFetch('/api/user/files', 'PATCH', { id: image.id, favorite: !image.favorite });
if (!data.error) updateImages(true);
showNotification({
title: 'Image is now ' + (!image.favorite ? 'favorited' : 'unfavorited'),
message: '',
icon: <StarIcon />,
});
};
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 />,
});
},
}
);
};
return (
<>
<Modal
opened={open}
onClose={() => setOpen(false)}
title={<Title>{image.file}</Title>}
size='xl'
>
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>{image.file}</Title>} size='xl'>
<LoadingOverlay visible={loading} />
<Stack>
<Type
file={image}
src={image.url}
src={`/r/${image.file}`}
alt={image.file}
popup
sx={{ minHeight: 200 }}
style={{ minHeight: 200 }}
disableMediaPreview={false}
/>
<Stack>
<FileMeta Icon={FileIcon} title='Name' subtitle={image.file} />
<FileMeta Icon={ImageIcon} title='Type' subtitle={image.mimetype} />
<FileMeta Icon={CalendarIcon} title='Uploaded at' subtitle={new Date(image.created_at).toLocaleString()} />
{image.expires_at && <FileMeta Icon={ClockIcon} title='Expires' subtitle={relativeTime(new Date(image.expires_at))} />}
<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.created_at))}
tooltip={new Date(image?.created_at).toLocaleString()}
/>
{image.expires_at && (
<FileMeta
Icon={ClockIcon}
title='Expires'
subtitle={relativeTime(new Date(image.expires_at))}
tooltip={new Date(image.expires_at).toLocaleString()}
/>
)}
<FileMeta Icon={HashIcon} title='ID' subtitle={image.id} />
</Stack>
</Stack>
<Group position='right' mt={22}>
<Button onClick={handleCopy}>Copy</Button>
<Group position='right' mt='md'>
<Button onClick={handleCopy}>Copy URL</Button>
<Button onClick={handleDelete}>Delete</Button>
<Button onClick={handleFavorite}>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
<Link href={image.url} target='_blank'>
<Button rightIcon={<ExternalLinkIcon />}>Open</Button>
</Link>
</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={image.url}
sx={{
minHeight: 200,
maxHeight: 320,
fontSize: 70,
width: '100%',
cursor: 'pointer',
}}
style={{
minHeight: 200,
maxHeight: 320,
fontSize: 70,
width: '100%',
cursor: 'pointer',
}}
src={`/r/${image.file}`}
alt={image.file}
onClick={() => setOpen(true)}
disableMediaPreview={disableMediaPreview}
/>
</Card.Section>
</Card>
</>
);
}
}

View File

@@ -1,168 +0,0 @@
/* eslint-disable react/jsx-key */
// Code taken from https://codesandbox.io/s/eojw8 and is modified a bit (the entire src/components/table directory)
import {
ActionIcon,
createStyles,
Divider,
Group, Image, Pagination,
Select,
Table,
Text,
useMantineTheme,
} from '@mantine/core';
import {
usePagination,
useTable,
} from 'react-table';
import { CopyIcon, DeleteIcon, EnterIcon } from './icons';
const pageSizeOptions = ['10', '25', '50'];
const useStyles = createStyles((t) => ({
root: { height: '100%', display: 'block', marginTop: 10 },
tableContainer: {
display: 'block',
overflow: 'auto',
'& > table': {
'& > thead': { backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0], zIndex: 1 },
'& > thead > tr > th': { padding: t.spacing.md },
'& > tbody > tr > td': { padding: t.spacing.md },
},
borderRadius: 6,
},
stickHeader: { top: 0, position: 'sticky' },
disableSortIcon: { color: t.colors.gray[5] },
sortDirectionIcon: { transition: 'transform 200ms ease' },
}));
export function FilePreview({ url, type }) {
const Type = props => {
return {
'video': <video autoPlay controls {...props} />,
'image': <Image {...props} />,
'audio': <audio autoPlay controls {...props} />,
}[type.split('/')[0]];
};
return (
<Type
sx={{ maxWidth: '10vw', maxHeight: '100vh' }}
style={{ maxWidth: '10vw', maxHeight: '100vh' }}
mr='sm'
src={url}
alt={'Unable to preview file'}
/>
);
}
export default function ImagesTable({
columns,
data = [],
serverSideDataSource = false,
initialPageSize = 10,
initialPageIndex = 0,
pageCount = 0,
total = 0,
deleteImage, copyImage, viewImage,
}) {
const { classes } = useStyles();
const theme = useMantineTheme();
const tableOptions = useTable(
{
data,
columns,
pageCount,
initialState: { pageSize: initialPageSize, pageIndex: initialPageIndex },
},
usePagination
);
const {
getTableProps, getTableBodyProps, headerGroups, rows, prepareRow, page, gotoPage, setPageSize, state: { pageIndex, pageSize },
} = tableOptions;
const getPageRecordInfo = () => {
const firstRowNum = pageIndex * pageSize + 1;
const totalRows = rows.length;
const currLastRowNum = (pageIndex + 1) * pageSize;
let lastRowNum = currLastRowNum < totalRows ? currLastRowNum : totalRows;
return `${firstRowNum} - ${lastRowNum} of ${totalRows}`;
};
const getPageCount = () => Math.ceil(rows.length / pageSize);
const handlePageChange = (pageNum) => gotoPage(pageNum - 1);
const renderHeader = () => headerGroups.map(hg => (
<tr {...hg.getHeaderGroupProps()}>
{hg.headers.map(column => (
<th {...column.getHeaderProps()}>
<Group noWrap position={column.align || 'apart'}>
<div>{column.render('Header')}</div>
</Group>
</th>
))}
<th>Actions</th>
</tr>
));
const renderRow = rows => rows.map(row => {
prepareRow(row);
return (
<tr {...row.getRowProps()}>
{row.cells.map(cell => (
<td align={cell.column.align || 'left'} {...cell.getCellProps()}>
{cell.render('Cell')}
</td>
))}
<td align='right'>
<Group noWrap>
<ActionIcon color='red' variant='outline' onClick={() => deleteImage(row)}><DeleteIcon /></ActionIcon>
<ActionIcon color='primary' variant='outline' onClick={() => copyImage(row)}><CopyIcon /></ActionIcon>
<ActionIcon color='green' variant='outline' onClick={() => viewImage(row)}><EnterIcon /></ActionIcon>
</Group>
</td>
</tr>
);
});
return (
<div className={classes.root}>
<div
className={classes.tableContainer}
style={{ height: 'calc(100% - 44px)' }}
>
<Table {...getTableProps()}>
<thead style={{ backgroundColor: theme.other.hover }}>
{renderHeader()}
</thead>
<tbody {...getTableBodyProps()}>
{renderRow(page)}
</tbody>
</Table>
</div>
<Divider mb='md' variant='dotted' />
<Group position='left'>
<Text size='sm'>Rows per page: </Text>
<Select
style={{ width: '72px' }}
variant='filled'
data={pageSizeOptions}
value={pageSize + ''}
onChange={pageSize => setPageSize(Number(pageSize))} />
<Divider orientation='vertical' />
<Text size='sm'>{getPageRecordInfo()}</Text>
<Divider orientation='vertical' />
<Pagination
page={pageIndex + 1}
total={getPageCount()}
onChange={handlePageChange} />
</Group>
</div>
);
}

View File

@@ -1,19 +1,65 @@
import { AppShell, Box, Burger, Button, Divider, Header, MediaQuery, Navbar, NavLink, Paper, Popover, ScrollArea, Select, Stack, Text, Title, UnstyledButton, useMantineTheme, Group, Image } from '@mantine/core';
import {
AppShell,
Box,
Burger,
Button,
Divider,
Header,
MediaQuery,
Navbar,
NavLink,
Paper,
Popover,
ScrollArea,
Select,
Stack,
Text,
Title,
UnstyledButton,
useMantineTheme,
Group,
Image,
Tooltip,
Badge,
Menu,
} from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import useFetch from 'hooks/useFetch';
import { updateUser } from 'lib/redux/reducers/user';
import { useStoreDispatch } from 'lib/redux/store';
import { useVersion } from 'lib/queries/version';
import { userSelector } from 'lib/recoil/user';
import { capitalize } from 'lib/utils/client';
import { useRecoilState } from 'recoil';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { ActivityIcon, CheckIcon, CopyIcon, CrossIcon, DeleteIcon, FileIcon, HomeIcon, LinkIcon, LogoutIcon, PencilIcon, SettingsIcon, TagIcon, TypeIcon, UploadIcon, UserIcon } from './icons';
import {
ExternalLinkIcon,
ActivityIcon,
CheckIcon,
CopyIcon,
CrossIcon,
DeleteIcon,
FileIcon,
HomeIcon,
LinkIcon,
LogoutIcon,
PencilIcon,
SettingsIcon,
TagIcon,
TypeIcon,
UploadIcon,
UserIcon,
DiscordIcon,
GitHubIcon,
GoogleIcon,
} from './icons';
import { friendlyThemeName, themes } from './Theming';
function MenuItemLink(props) {
return (
<Link href={props.href} passHref>
<Link href={props.href} passHref legacyBehavior>
<MenuItem {...props} />
</Link>
);
@@ -22,7 +68,7 @@ function MenuItemLink(props) {
function MenuItem(props) {
return (
<UnstyledButton
sx={theme => ({
sx={(theme) => ({
display: 'block',
width: '100%',
padding: 5,
@@ -30,30 +76,34 @@ function MenuItem(props) {
color: props.color
? theme.fn.themeColor(props.color, theme.colorScheme === 'dark' ? 5 : 7)
: theme.colorScheme === 'dark'
? theme.colors.dark[0]
: theme.black,
'&:hover': {
backgroundColor: props.color
? theme.fn.rgba(
theme.fn.themeColor(props.color, theme.colorScheme === 'dark' ? 9 : 0),
theme.colorScheme === 'dark' ? 0.2 : 1
)
: theme.colorScheme === 'dark'
? theme.fn.rgba(theme.colors.dark[3], 0.35)
: theme.colors.gray[0],
},
? theme.colors.dark[0]
: theme.black,
'&:hover': !props.noClick
? {
backgroundColor: props.color
? theme.fn.rgba(
theme.fn.themeColor(props.color, theme.colorScheme === 'dark' ? 9 : 0),
theme.colorScheme === 'dark' ? 0.2 : 1
)
: theme.colorScheme === 'dark'
? theme.fn.rgba(theme.colors.dark[3], 0.35)
: theme.colors.gray[0],
}
: null,
})}
{...props}
>
<Group noWrap>
<Box sx={theme => ({
marginRight: theme.spacing.xs / 4,
paddingLeft: theme.spacing.xs / 2,
<Box
sx={(theme) => ({
marginRight: theme.spacing.xs / 4,
paddingLeft: theme.spacing.xs / 2,
'& *': {
display: 'block',
},
})}>
'& *': {
display: 'block',
},
})}
>
{props.icon}
</Box>
<Text size='sm'>{props.children}</Text>
@@ -100,34 +150,52 @@ const admin_items = [
icon: <UserIcon size={18} />,
text: 'Users',
link: '/dashboard/users',
if: (props) => true,
},
{
icon: <TagIcon size={18} />,
text: 'Invites',
link: '/dashboard/invites',
if: (props) => props.invites,
},
];
export default function Layout({ children, user, title }) {
export default function Layout({ children, props }) {
const [user, setUser] = useRecoilState(userSelector);
const { title, oauth_providers: unparsed } = props;
const oauth_providers = JSON.parse(unparsed);
const icons = {
GitHub: GitHubIcon,
Discord: DiscordIcon,
Google: GoogleIcon,
};
for (const provider of oauth_providers) {
provider.Icon = icons[provider.name];
}
const external_links = JSON.parse(props.external_links ?? '[]');
const [token, setToken] = useState(user?.token);
const [systemTheme, setSystemTheme] = useState(user.systemTheme ?? 'system');
const [avatar, setAvatar] = useState(user.avatar ?? null);
const version = useVersion();
const [opened, setOpened] = useState(false); // navigation open
const [open, setOpen] = useState(false); // manage acc dropdown
const avatar = user?.avatar ?? null;
const router = useRouter();
const dispatch = useStoreDispatch();
const theme = useMantineTheme();
const modals = useModals();
const clipboard = useClipboard();
const handleUpdateTheme = async value => {
const handleUpdateTheme = async (value) => {
const newUser = await useFetch('/api/user', 'PATCH', {
systemTheme: value || 'dark_blue',
});
setSystemTheme(newUser.systemTheme);
dispatch(updateUser(newUser));
setUser(newUser);
router.replace(router.pathname);
showNotification({
@@ -138,76 +206,72 @@ export default function Layout({ children, user, title }) {
});
};
const openResetToken = () => modals.openConfirmModal({
title: 'Reset Token',
children: (
<Text size='sm'>
Once you reset your token, you will have to update any uploaders to use this new token.
</Text>
),
labels: { confirm: 'Reset', cancel: 'Cancel' },
onConfirm: async () => {
const a = await useFetch('/api/user/token', 'PATCH');
if (!a.success) {
setToken(a.success);
const openResetToken = () =>
modals.openConfirmModal({
title: <Title>Reset Token?</Title>,
children: (
<Text size='sm'>
Once you reset your token, you will have to update any uploaders to use this new token.
</Text>
),
labels: { confirm: 'Reset', cancel: 'Cancel' },
onConfirm: async () => {
const a = await useFetch('/api/user/token', 'PATCH');
if (!a.success) {
setToken(a.success);
showNotification({
title: 'Token Reset Failed',
message: a.error,
color: 'red',
icon: <CrossIcon />,
});
} else {
showNotification({
title: 'Token Reset',
message:
'Your token has been reset. You will need to update any uploaders to use this new token.',
color: 'green',
icon: <CheckIcon />,
});
}
modals.closeAll();
},
});
const openCopyToken = () =>
modals.openConfirmModal({
title: <Title>Copy Token</Title>,
children: (
<Text size='sm'>
Make sure you don&apos;t share this token with anyone as they will be able to upload files on your
behalf.
</Text>
),
labels: { confirm: 'Copy', cancel: 'Cancel' },
onConfirm: async () => {
clipboard.copy(token);
showNotification({
title: 'Token Reset Failed',
message: a.error,
color: 'red',
icon: <CrossIcon />,
});
} else {
showNotification({
title: 'Token Reset',
message: 'Your token has been reset. You will need to update any uploaders to use this new token.',
title: 'Token Copied',
message: 'Your token has been copied to your clipboard.',
color: 'green',
icon: <CheckIcon />,
});
}
modals.closeAll();
},
});
const openCopyToken = () => modals.openConfirmModal({
title: 'Copy Token',
children: (
<Text size='sm'>
Make sure you don&apos;t share this token with anyone as they will be able to upload files on your behalf.
</Text>
),
labels: { confirm: 'Copy', cancel: 'Cancel' },
onConfirm: async () => {
clipboard.copy(token);
showNotification({
title: 'Token Copied',
message: 'Your token has been copied to your clipboard.',
color: 'green',
icon: <CheckIcon />,
});
modals.closeAll();
},
});
modals.closeAll();
},
});
return (
<AppShell
navbarOffsetBreakpoint='sm'
fixed
navbar={
<Navbar
pt='sm'
hiddenBreakpoint='sm'
hidden={!opened}
width={{ sm: 200, lg: 230 }}
>
<Navbar.Section
grow
component={ScrollArea}
>
<Navbar pt='sm' hiddenBreakpoint='sm' hidden={!opened} width={{ sm: 200, lg: 230 }}>
<Navbar.Section grow component={ScrollArea}>
{items.map(({ icon, text, link }) => (
<Link href={link} key={text} passHref>
<Link href={link} key={text} passHref legacyBehavior>
<NavLink
component='a'
label={text}
@@ -222,22 +286,60 @@ export default function Layout({ children, user, title }) {
label='Administration'
icon={<SettingsIcon />}
childrenOffset={28}
defaultOpened={admin_items.map(x => x.link).includes(router.pathname)}
defaultOpened={admin_items.map((x) => x.link).includes(router.pathname)}
>
{admin_items.map(({ icon, text, link }) => (
<Link href={link} key={text} passHref>
<NavLink
component='a'
label={text}
icon={icon}
active={router.pathname === link}
variant='light'
/>
</Link>
))}
{admin_items
.filter((x) => x.if(props))
.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>
)}
</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>
))
: null}
</Navbar.Section>
{version.isSuccess ? (
<Navbar.Section>
<Tooltip
label={
version.data.local !== version.data.upstream
? `You are running an outdated version of Zipline, refer to the docs on how to update to ${version.data.upstream}`
: 'You are running the latest version of Zipline'
}
>
<Badge
m='md'
radius='md'
size='lg'
variant='dot'
color={version.data.local !== version.data.upstream ? 'red' : 'primary'}
>
{version.data.local}
</Badge>
</Tooltip>
</Navbar.Section>
) : null}
</Navbar>
}
header={
@@ -253,20 +355,17 @@ export default function Layout({ children, user, title }) {
</MediaQuery>
<Title ml='sm'>{title}</Title>
<Box sx={{ marginLeft: 'auto', marginRight: 0 }}>
<Popover
position='bottom-end'
opened={open}
onClose={() => setOpen(false)}
>
<Popover position='bottom-end' opened={open} onClose={() => setOpen(false)}>
<Popover.Target>
<Button
leftIcon={avatar ? <Image src={avatar} height={32} radius='md' /> : <SettingsIcon />}
onClick={() => setOpen((o) => !o)}
sx={t => ({
backgroundColor: '#00000000',
sx={(t) => ({
backgroundColor: 'inherit',
'&:hover': {
backgroundColor: t.other.hover,
},
color: t.colorScheme === 'dark' ? 'white' : 'black',
})}
size='xl'
p='sm'
@@ -275,35 +374,73 @@ export default function Layout({ children, user, title }) {
</Button>
</Popover.Target>
<Popover.Dropdown p={4}>
<Popover.Dropdown p={4} mr='md' sx={{ minWidth: '200px' }}>
<Stack spacing={2}>
<Text sx={{
color: theme.colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6],
fontWeight: 500,
fontSize: theme.fontSizes.sm,
padding: `${theme.spacing.xs / 2}px ${theme.spacing.sm}px`,
cursor: 'default',
}}
<Menu.Label>
{user.username} ({user.id}){' '}
{user.administrator && user.username !== 'administrator' ? '(Administrator)' : ''}
</Menu.Label>
<MenuItemLink icon={<SettingsIcon />} href='/dashboard/manage'>
Manage Account
</MenuItemLink>
<MenuItem
icon={<CopyIcon />}
onClick={() => {
setOpen(false);
openCopyToken();
}}
>
{user.username}
</Text>
<MenuItemLink icon={<SettingsIcon />} href='/dashboard/manage'>Manage Account</MenuItemLink>
<MenuItem icon={<CopyIcon />} onClick={() => {setOpen(false);openCopyToken();}}>Copy Token</MenuItem>
<MenuItem icon={<DeleteIcon />} onClick={() => {setOpen(false);openResetToken();}} color='red'>Reset Token</MenuItem>
<MenuItemLink icon={<LogoutIcon />} href='/auth/logout' color='red'>Logout</MenuItemLink>
<Divider
variant='solid'
my={theme.spacing.xs / 2}
sx={theme => ({
width: '110%',
borderTopColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[2],
margin: `${theme.spacing.xs / 2}px -4px`,
})}
/>
Copy Token
</MenuItem>
<MenuItem
icon={<DeleteIcon />}
onClick={() => {
setOpen(false);
openResetToken();
}}
color='red'
>
Reset Token
</MenuItem>
<MenuItemLink icon={<LogoutIcon />} href='/auth/logout' color='red'>
Logout
</MenuItemLink>
<Menu.Divider />
<>
{oauth_providers
.filter((x) =>
user.oauth
?.map(({ provider }) => provider.toLowerCase())
.includes(x.name.toLowerCase())
)
.map(({ name, Icon }, i) => (
<>
<MenuItem
sx={{ '&:hover': { backgroundColor: 'inherit' } }}
key={i}
py={5}
px={4}
icon={<Icon size={18} colorScheme={theme.colorScheme} />}
>
Logged in with {capitalize(name)}
</MenuItem>
</>
))}
{oauth_providers.filter((x) =>
user.oauth
?.map(({ provider }) => provider.toLowerCase())
.includes(x.name.toLowerCase())
).length ? (
<Menu.Divider />
) : null}
</>
<MenuItem icon={<PencilIcon />}>
<Select
size='xs'
data={Object.keys(themes).map(t => ({ value: t, label: friendlyThemeName[t] }))}
<Select
size='xs'
data={Object.keys(themes).map((t) => ({
value: t,
label: friendlyThemeName[t],
}))}
value={systemTheme}
onChange={handleUpdateTheme}
/>
@@ -320,7 +457,7 @@ export default function Layout({ children, user, title }) {
withBorder
p='md'
shadow='xs'
sx={t => ({
sx={(t) => ({
borderColor: t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.dark[0],
})}
>
@@ -328,4 +465,4 @@ export default function Layout({ children, user, title }) {
</Paper>
</AppShell>
);
}
}

View File

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

View File

@@ -1,5 +1,9 @@
import { Text } from '@mantine/core';
export default function MutedText({ children, ...props }) {
return <Text color='dimmed' size='xl' {...props}>{children}</Text>;
}
return (
<Text color='dimmed' size='xl' {...props}>
{children}
</Text>
);
}

View File

@@ -6,12 +6,7 @@ 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'
>
<Text color={meets ? 'teal' : 'red'} sx={{ display: 'flex', alignItems: 'center' }} mt='sm' size='sm'>
{meets ? <CheckIcon /> : <CrossIcon />} <Box ml='md'>{label}</Box>
</Text>
);
@@ -49,16 +44,18 @@ export default function PasswordStrength({ value, setValue, setStrength, ...prop
return (
<Popover
opened={popoverOpened}
position='top'
position='bottom'
width='target'
withArrow
trapFocus={false}
styles={{
dropdown: {
zIndex: 999999,
},
}}
>
<Popover.Target>
<div
onFocusCapture={() => setPopoverOpened(true)}
onBlurCapture={() => setPopoverOpened(false)}
>
<div onFocusCapture={() => setPopoverOpened(true)} onBlurCapture={() => setPopoverOpened(false)}>
<PasswordInput
label='Password'
description='A strong password should include letters in lower and uppercase, at least 1 number, at least 1 special symbol'
@@ -68,7 +65,7 @@ export default function PasswordStrength({ value, setValue, setStrength, ...prop
/>
</div>
</Popover.Target>
<Popover.Dropdown sx={{ }}>
<Popover.Dropdown>
<Progress color={color} value={strength} size={7} mb='md' />
<PasswordRequirement label='Includes at least 8 characters' meets={value.length > 7} />
{checks}

View File

@@ -7,18 +7,16 @@ export function SmallTable({ rows, columns }) {
<Table highlightOnHover>
<thead>
<tr>
{columns.map(col => (
{columns.map((col) => (
<th key={randomId()}>{col.name}</th>
))}
</tr>
</thead>
<tbody>
{rows.map(row => (
{rows.map((row) => (
<tr key={randomId()}>
{columns.map(col => (
<td key={randomId()}>
{col.format ? col.format(row[col.id]) : row[col.id]}
</td>
{columns.map((col) => (
<td key={randomId()}>{col.format ? col.format(row[col.id]) : row[col.id]}</td>
))}
</tr>
))}
@@ -26,4 +24,4 @@ export function SmallTable({ rows, columns }) {
</Table>
</Box>
);
}
}

View File

@@ -0,0 +1,71 @@
import { Card, createStyles, Group, Text } from '@mantine/core';
import { ArrowDownRight, ArrowUpRight } from 'react-feather';
const useStyles = createStyles((theme) => ({
root: {
padding: theme.spacing.xl * 1.5,
},
value: {
fontSize: 24,
fontWeight: 700,
lineHeight: 1,
},
diff: {
lineHeight: 1,
display: 'flex',
alignItems: 'center',
},
icon: {
color: theme.colorScheme === 'dark' ? theme.colors.dark[3] : theme.colors.gray[4],
},
title: {
fontWeight: 700,
textTransform: 'uppercase',
},
}));
interface StatsGridProps {
stat: {
title: string;
icon: React.ReactNode;
value: string;
desc: string;
diff?: number;
};
}
export default function StatCard({ stat }: StatsGridProps) {
const { classes } = useStyles();
if (stat.diff) stat.diff = Math.round(stat.diff);
return (
<Card p='md' radius='md' key={stat.title}>
<Group position='apart'>
<Text size='xs' color='dimmed' className={classes.title}>
{stat.title}
</Text>
{stat.icon}
</Group>
<Group align='flex-end' spacing='xs' mt='md'>
<Text className={classes.value}>{stat.value}</Text>
{typeof stat.diff == 'number' && (
<>
<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} />}
</Text>
</>
)}
</Group>
<Text size='xs' color='dimmed' mt='sm'>
{stat.desc}
</Text>
</Card>
);
}

View File

@@ -16,10 +16,11 @@ import { MantineProvider, MantineThemeOverride } from '@mantine/core';
import { useColorScheme } from '@mantine/hooks';
import { ModalsProvider } from '@mantine/modals';
import { NotificationsProvider } from '@mantine/notifications';
import { useStoreSelector } from 'lib/redux/store';
import { useRecoilValue } from 'recoil';
import { userSelector } from 'lib/recoil/user';
export const themes = {
system: (colorScheme: 'dark' | 'light') => colorScheme === 'dark' ? dark_blue : light_blue,
system: (colorScheme: 'dark' | 'light') => (colorScheme === 'dark' ? dark_blue : light_blue),
dark_blue,
light_blue,
dark,
@@ -33,21 +34,21 @@ export const themes = {
};
export const friendlyThemeName = {
'system': 'System Theme',
'dark_blue': 'Dark Blue',
'light_blue': 'Light Blue',
'dark': 'Very Dark',
'ayu_dark': 'Ayu Dark',
'ayu_mirage': 'Ayu Mirage',
'ayu_light': 'Ayu Light',
'nord': 'Nord',
'dracula': 'Dracula',
'matcha_dark_azul': 'Matcha Dark Azul',
'qogir_dark': 'Qogir Dark',
system: 'System Theme',
dark_blue: 'Dark Blue',
light_blue: 'Light Blue',
dark: 'Very Dark',
ayu_dark: 'Ayu Dark',
ayu_mirage: 'Ayu Mirage',
ayu_light: 'Ayu Light',
nord: 'Nord',
dracula: 'Dracula',
matcha_dark_azul: 'Matcha Dark Azul',
qogir_dark: 'Qogir Dark',
};
export default function ZiplineTheming({ Component, pageProps, ...props }) {
const user = useStoreSelector(state => state.user);
const user = useRecoilValue(userSelector);
const colorScheme = useColorScheme();
let theme: MantineThemeOverride;
@@ -68,14 +69,14 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
...theme,
components: {
AppShell: {
styles: t => ({
styles: (t) => ({
root: {
backgroundColor: t.other.AppShell_backgroundColor,
},
}),
},
NavLink: {
styles: t => ({
styles: (t) => ({
icon: {
paddingLeft: t.spacing.sm,
},
@@ -93,6 +94,31 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
transition: 'pop',
},
},
LoadingOverlay: {
defaultProps: {
overlayBlur: 3,
overlayColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
},
},
Loader: {
defaultProps: {
variant: 'dots',
},
},
Card: {
styles: (t) => ({
root: {
backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0],
},
}),
},
Image: {
styles: (t) => ({
placeholder: {
backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0],
},
}),
},
},
}}
>
@@ -103,4 +129,4 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
</ModalsProvider>
</MantineProvider>
);
}
}

View File

@@ -4,19 +4,28 @@ import { useEffect, useState } from 'react';
import { AudioIcon, FileIcon, PlayIcon } from './icons';
function Placeholder({ text, Icon, ...props }) {
if (props.disableResolve) props.src = null;
return (
<Image height={200} withPlaceholder placeholder={
<Group>
<Icon size={48} />
<Text size='md'>{text}</Text>
</Group>
} {...props} />
<Image
height={200}
withPlaceholder
placeholder={
<Group>
<Icon size={48} />
<Text size='md'>{text}</Text>
</Group>
}
{...props}
/>
);
}
export default function Type({ file, popup = false, ...props }){
export default function Type({ file, popup = false, disableMediaPreview, ...props }) {
const type = (file.type || file.mimetype).split('/')[0];
const name = (file.name || file.file);
const name = file.name || file.file;
const media = /^(video|audio|image|text)/.test(type);
const [text, setText] = useState('');
@@ -31,15 +40,35 @@ export default function Type({ file, popup = false, ...props }){
}, []);
}
return popup ? {
'video': <video width='100%' autoPlay controls {...props} />,
'image': <Image {...props} />,
'audio': <audio autoPlay controls {...props} style={{ width: '100%' }}/>,
'text': <Prism withLineNumbers language={name.split('.').pop()} {...props} style={{}} sx={{}}>{text}</Prism>,
}[type] : {
'video': <Placeholder Icon={PlayIcon} text={`Click to view video (${name})`} {...props} />,
'image': <Image {...props} />,
'audio': <Placeholder Icon={AudioIcon} text={`Click to view audio (${name})`} {...props}/>,
'text': <Placeholder Icon={FileIcon} text={`Click to view text file (${name})`} {...props}/>,
}[type];
};
if (media && disableMediaPreview) {
return (
<Placeholder Icon={FileIcon} text={`Click to view file (${name})`} disableResolve={true} {...props} />
);
}
return popup ? (
media ? (
{
video: <video width='100%' autoPlay controls {...props} />,
image: <Image {...props} />,
audio: <audio autoPlay controls {...props} style={{ width: '100%' }} />,
text: (
<Prism withLineNumbers language={name.split('.').pop()} {...props} style={{}} sx={{}}>
{text}
</Prism>
),
}[type]
) : (
<Text>Can&apos;t preview {file.type || file.mimetype}</Text>
)
) : media ? (
{
video: <Placeholder Icon={PlayIcon} text={`Click to view video (${name})`} {...props} />,
image: <Image {...props} />,
audio: <Placeholder Icon={AudioIcon} text={`Click to view audio (${name})`} {...props} />,
text: <Placeholder Icon={FileIcon} text={`Click to view text file (${name})`} {...props} />,
}[type]
) : (
<Placeholder Icon={FileIcon} text={`Click to view file (${name})`} {...props} />
);
}

View File

@@ -12,13 +12,11 @@ export default function Dropzone({ loading, onDrop, children }) {
<ImageIcon size={80} />
<Text size='xl' inline>
Drag images here or click to select files
Drag files here or click to select files
</Text>
</Group>
<div style={{ pointerEvents: 'all' }}>
{children}
</div>
<div style={{ pointerEvents: 'all' }}>{children}</div>
</MantineDropzone>
);
}
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Table, Tooltip, Badge, useMantineTheme } from '@mantine/core';
import { Table, Tooltip, Badge, HoverCard, Text, useMantineTheme, Group } from '@mantine/core';
import Type from 'components/Type';
export function FilePreview({ file }: { file: File }) {
@@ -11,6 +11,7 @@ export function FilePreview({ file }: { file: File }) {
style={{ maxWidth: '10vw', maxHeight: '100vh' }}
src={URL.createObjectURL(file)}
alt={file.name}
disableMediaPreview={false}
popup
/>
);
@@ -20,10 +21,12 @@ export default function FileDropzone({ file }: { file: File }) {
const theme = useMantineTheme();
return (
<Tooltip
position='top'
label={
<div style={{ display: 'flex', alignItems: 'center' }}>
<HoverCard shadow='md'>
<HoverCard.Target>
<Badge size='lg'>{file.name}</Badge>
</HoverCard.Target>
<HoverCard.Dropdown>
<Group grow>
<FilePreview file={file} />
<Table sx={{ color: theme.colorScheme === 'dark' ? 'white' : 'white' }} ml='md'>
@@ -42,12 +45,8 @@ export default function FileDropzone({ file }: { file: File }) {
</tr>
</tbody>
</Table>
</div>
}
>
<Badge size='lg'>
{file.name}
</Badge>
</Tooltip>
</Group>
</HoverCard.Dropdown>
</HoverCard>
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
// https://discord.com/branding
import Image from 'next/image';
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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
// https://github.com/flameshot-org/flameshot/blob/master/data/img/app/flameshot.svg
import Image from 'next/image';
export default function FlameshotIcon({ ...props }) {
return (
<Image
alt='flameshot'
src='https://raw.githubusercontent.com/flameshot-org/flameshot/master/data/img/app/flameshot.svg'
width={24}
height={24}
{...props}
/>
);
}

View File

@@ -0,0 +1,17 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
// https://getsharex.com/brand-assets/
import Image from 'next/image';
export default function ShareXIcon({ ...props }) {
return (
<Image alt='sharex' src='https://getsharex.com/img/ShareX_Logo.svg' width={24} height={24} {...props} />
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,15 @@ 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';
export {
ActivityIcon,
@@ -50,4 +59,13 @@ export {
HashIcon,
TagIcon,
ClockIcon,
};
ExternalLinkIcon,
ShareXIcon,
DownloadIcon,
FlameshotIcon,
GitHubIcon,
DiscordIcon,
GoogleIcon,
EyeIcon,
RefreshIcon,
};

View File

@@ -1,133 +0,0 @@
import { SimpleGrid, Skeleton, Text, Title } from '@mantine/core';
import { randomId, useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import Card from 'components/Card';
import File from 'components/File';
import { CopyIcon, CrossIcon, DeleteIcon } from 'components/icons';
import ImagesTable from 'components/ImagesTable';
import Link from 'components/Link';
import MutedText from 'components/MutedText';
import { bytesToRead } from 'lib/clientUtils';
import useFetch from 'lib/hooks/useFetch';
import { useStoreSelector } from 'lib/redux/store';
import { useEffect, useState } from 'react';
type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify';
export default function Dashboard() {
const user = useStoreSelector(state => state.user);
const [images, setImages] = useState([]);
const [recent, setRecent] = useState([]);
const [stats, setStats] = useState(null);
const clipboard = useClipboard();
const updateImages = async () => {
const imgs = await useFetch('/api/user/files');
const recent = await useFetch('/api/user/recent?filter=media');
const stts = await useFetch('/api/stats');
setImages(imgs.map(x => ({ ...x, created_at: new Date(x.created_at).toLocaleString() })));
setStats(stts);
setRecent(recent);
};
const deleteImage = async ({ original }) => {
const res = await useFetch('/api/user/files', 'DELETE', { id: original.id });
if (!res.error) {
updateImages();
showNotification({
title: 'Image Deleted',
message: '',
color: 'green',
icon: <DeleteIcon />,
});
} else {
showNotification({
title: 'Failed to delete image',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
}
};
const copyImage = async ({ original }) => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${original.url}`);
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
};
const viewImage = async ({ original }) => {
window.open(`${window.location.protocol}//${window.location.host}${original.url}`);
};
useEffect(() => {
updateImages();
}, []);
return (
<>
<Title>Welcome back, {user?.username}</Title>
<MutedText size='md'>You have <b>{images.length ? images.length : '...'}</b> files</MutedText>
<Title>Recent Files</Title>
<SimpleGrid
cols={4}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{recent.length ? recent.map(image => (
<File key={randomId()} image={image} updateImages={updateImages} />
)) : [1, 2, 3, 4].map(x => (
<div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
</div>
))}
</SimpleGrid>
<Title mt='md'>Stats</Title>
<MutedText size='md'>View more stats here <Link href='/dashboard/stats'>here</Link>.</MutedText>
<SimpleGrid
cols={3}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
<Card name='Size' sx={{ height: '100%' }}>
<MutedText>{stats ? stats.size : <Skeleton height={8} />}</MutedText>
<Title order={2}>Average Size</Title>
<MutedText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</MutedText>
</Card>
<Card name='Images' sx={{ height: '100%' }}>
<MutedText>{stats ? stats.count : <Skeleton height={8} />}</MutedText>
<Title order={2}>Views</Title>
<MutedText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</MutedText>
</Card>
<Card name='Users' sx={{ height: '100%' }}>
<MutedText>{stats ? stats.count_users : <Skeleton height={8} />}</MutedText>
</Card>
</SimpleGrid>
<Title mt='md'>Files</Title>
<MutedText size='md'>View your gallery <Link href='/dashboard/files'>here</Link>.</MutedText>
<ImagesTable
columns={[
{ accessor: 'file', Header: 'Name', minWidth: 170, align: 'inherit' as Aligns },
{ accessor: 'mimetype', Header: 'Type', minWidth: 100, align: 'inherit' as Aligns },
{ accessor: 'created_at', Header: 'Date' },
]}
data={images}
deleteImage={deleteImage}
copyImage={copyImage}
viewImage={viewImage}
/>
</>
);
}

View File

@@ -0,0 +1,54 @@
import { Box, Card as MantineCard, Center, Group, SimpleGrid, Skeleton, Title } from '@mantine/core';
import { randomId } from '@mantine/hooks';
import File from 'components/File';
import MutedText from 'components/MutedText';
import { invalidateFiles, useRecent } from 'lib/queries/files';
import { UploadCloud } from 'react-feather';
export default function RecentFiles({ disableMediaPreview }) {
const recent = useRecent('media');
return (
<>
<Title mt='sm'>Recent Files</Title>
<SimpleGrid
cols={recent.isSuccess && recent.data.length === 0 ? 1 : 4}
spacing='lg'
breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}
>
{recent.isSuccess ? (
recent.data.length > 0 ? (
recent.data.map((image) => (
<File
key={randomId()}
image={image}
updateImages={invalidateFiles}
disableMediaPreview={disableMediaPreview}
/>
))
) : (
<MantineCard shadow='md'>
<Center>
<Group>
<div>
<UploadCloud size={48} />
</div>
<div>
<Title>Nothing here</Title>
<MutedText size='md'>Upload some files and they will show up here.</MutedText>
</div>
</Group>
</Center>
</MantineCard>
)
) : (
[1, 2, 3, 4].map((x) => (
<div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
</div>
))
)}
</SimpleGrid>
</>
);
}

View File

@@ -0,0 +1,69 @@
import { SimpleGrid } from '@mantine/core';
import { FileIcon } from 'components/icons';
import StatCard from 'components/StatCard';
import { percentChange } from 'lib/utils/client';
import { useStats } from 'lib/queries/stats';
import { Database, Eye, Users } from 'react-feather';
export function StatCards() {
const stats = useStats();
const latest = stats.data?.[0];
const before = stats.data?.[1];
return (
<SimpleGrid
cols={4}
breakpoints={[
{ maxWidth: 'md', cols: 2 },
{ maxWidth: 'xs', cols: 1 },
]}
my='sm'
>
<StatCard
stat={{
title: 'UPLOADED FILES',
value: stats.isSuccess ? latest.data.count.toLocaleString() : '...',
desc: 'files have been uploaded',
icon: <FileIcon />,
diff:
stats.isSuccess && before?.data ? percentChange(before.data.count, latest.data.count) : undefined,
}}
/>
<StatCard
stat={{
title: 'STORAGE',
value: stats.isSuccess ? latest.data.size : '...',
desc: 'of storage used',
icon: <Database size={15} />,
diff:
stats.isSuccess && before?.data
? percentChange(before.data.size_num, latest.data.size_num)
: undefined,
}}
/>
<StatCard
stat={{
title: 'VIEWS',
value: stats.isSuccess ? latest.data.views_count.toLocaleString() : '...',
desc: 'total page views',
icon: <Eye size={15} />,
diff:
stats.isSuccess && before?.data
? percentChange(before.data.views_count, latest.data.views_count)
: undefined,
}}
/>
<StatCard
stat={{
title: 'USERS',
value: stats.isSuccess ? latest.data.count_users.toLocaleString() : '...',
desc: 'total registered users',
icon: <Users size={15} />,
}}
/>
</SimpleGrid>
);
}

View File

@@ -0,0 +1,153 @@
import { DataGrid, dateFilterFn, stringFilterFn } from '@dicedtomato/mantine-data-grid';
import { Title, useMantineTheme, Box } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { CopyIcon, CrossIcon, DeleteIcon, EnterIcon } from 'components/icons';
import Link from 'components/Link';
import MutedText from 'components/MutedText';
import useFetch from 'lib/hooks/useFetch';
import { useFiles, useRecent } from 'lib/queries/files';
import { useStats } from 'lib/queries/stats';
import { userSelector } from 'lib/recoil/user';
import { useRecoilValue } from 'recoil';
import RecentFiles from './RecentFiles';
import { StatCards } from './StatCards';
export default function Dashboard({ disableMediaPreview }) {
const user = useRecoilValue(userSelector);
const theme = useMantineTheme();
const images = useFiles();
const recent = useRecent('media');
const stats = useStats();
const clipboard = useClipboard();
const updateImages = () => {
images.refetch();
recent.refetch();
stats.refetch();
};
const deleteImage = async ({ original }) => {
const res = await useFetch('/api/user/files', 'DELETE', {
id: original.id,
});
if (!res.error) {
updateImages();
showNotification({
title: 'Image Deleted',
message: '',
color: 'green',
icon: <DeleteIcon />,
});
} else {
showNotification({
title: 'Failed to delete image',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
}
};
const copyImage = async ({ original }) => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${original.url}`);
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
};
const viewImage = async ({ original }) => {
window.open(`${window.location.protocol}//${window.location.host}${original.url}`);
};
return (
<div>
<Title>Welcome back, {user?.username}</Title>
<MutedText size='md'>
You have <b>{images.isSuccess ? images.data.length : '...'}</b> files
</MutedText>
<StatCards />
<RecentFiles disableMediaPreview={disableMediaPreview} />
<Box my='sm'>
<Title>Files</Title>
<MutedText size='md'>
View your gallery <Link href='/dashboard/files'>here</Link>.
</MutedText>
<DataGrid
data={images.data ?? []}
loading={images.isLoading}
withPagination={true}
withColumnResizing={false}
withColumnFilters={true}
noEllipsis={true}
withSorting={true}
highlightOnHover={true}
CopyIcon={CopyIcon}
DeleteIcon={DeleteIcon}
EnterIcon={EnterIcon}
deleteImage={deleteImage}
copyImage={copyImage}
viewImage={viewImage}
styles={{
dataCell: {
width: '100%',
},
td: {
':nth-child(1)': {
minWidth: 170,
},
':nth-child(2)': {
minWidth: 100,
},
},
th: {
':nth-child(1)': {
minWidth: 170,
padding: theme.spacing.lg,
borderTopLeftRadius: theme.radius.sm,
},
':nth-child(2)': {
minWidth: 100,
padding: theme.spacing.lg,
},
':nth-child(3)': {
padding: theme.spacing.lg,
},
':nth-child(4)': {
padding: theme.spacing.lg,
borderTopRightRadius: theme.radius.sm,
},
},
thead: {
backgroundColor: theme.colors.dark[6],
},
}}
empty={<></>}
columns={[
{
accessorKey: 'file',
header: 'Name',
filterFn: stringFilterFn,
},
{
accessorKey: 'mimetype',
header: 'Type',
filterFn: stringFilterFn,
},
{
accessorKey: 'created_at',
header: 'Date',
filterFn: dateFilterFn,
},
]}
/>
</Box>
</div>
);
}

View File

@@ -1,103 +0,0 @@
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Skeleton, Title } from '@mantine/core';
import File from 'components/File';
import { PlusIcon } from 'components/icons';
import useFetch from 'hooks/useFetch';
import Link from 'next/link';
import { useEffect, useState } from 'react';
export default function Files() {
const [pages, setPages] = useState([]);
const [page, setPage] = useState(1);
const [favoritePages, setFavoritePages] = useState([]);
const [favoritePage, setFavoritePage] = useState(1);
const updatePages = async favorite => {
const pages = await useFetch('/api/user/files?paged=true&filter=media');
if (favorite) {
const fPages = await useFetch('/api/user/files?paged=true&favorite=media');
setFavoritePages(fPages);
}
setPages(pages);
};
useEffect(() => {
updatePages(true);
}, []);
return (
<>
<Group mb='md'>
<Title>Files</Title>
<Link href='/dashboard/upload' passHref>
<ActionIcon component='a' variant='filled' color='primary'><PlusIcon/></ActionIcon>
</Link>
</Group>
{favoritePages.length ? (
<Accordion
variant='contained'
mb='sm'
>
<Accordion.Item value='favorite'>
<Accordion.Control>Favorite Files</Accordion.Control>
<Accordion.Panel>
<SimpleGrid
cols={3}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{favoritePages.length ? favoritePages[(favoritePage - 1) ?? 0].map(image => (
<div key={image.id}>
<File image={image} updateImages={() => updatePages(true)} />
</div>
)) : null}
</SimpleGrid>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingTop: 12,
paddingBottom: 3,
}}
>
<Pagination total={favoritePages.length} page={favoritePage} onChange={setFavoritePage}/>
</Box>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
) : null}
<SimpleGrid
cols={3}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{pages.length ? pages[(page - 1) ?? 0].map(image => (
<div key={image.id}>
<File image={image} updateImages={() => updatePages(true)} />
</div>
)) : [1,2,3,4].map(x => (
<div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
</div>
))}
</SimpleGrid>
{pages.length ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingTop: 12,
paddingBottom: 3,
}}
>
<Pagination total={pages.length} page={page} onChange={setPage}/>
</Box>
) : null}
</>
);
}

View File

@@ -0,0 +1,72 @@
import { Box, Center, Checkbox, Group, Pagination, SimpleGrid, Skeleton, Text, Title } from '@mantine/core';
import File from 'components/File';
import { FileIcon } from 'components/icons';
import MutedText from 'components/MutedText';
import { usePaginatedFiles } from 'lib/queries/files';
import { useState } from 'react';
export default function FilePagation({ disableMediaPreview }) {
const [checked, setChecked] = useState(false);
const pages = usePaginatedFiles(!checked ? { filter: 'media' } : {});
const [page, setPage] = useState(1);
if (pages.isSuccess && pages.data.length === 0) {
return (
<Center>
<Group>
<div>
<FileIcon size={48} />
</div>
<div>
<Title>Nothing here</Title>
<MutedText size='md'>Upload some files and they will show up here.</MutedText>
</div>
</Group>
</Center>
);
}
return (
<>
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{pages.isSuccess
? pages.data.length
? pages.data[page - 1 ?? 0].map((image) => (
<div key={image.id}>
<File
image={image}
updateImages={() => pages.refetch()}
disableMediaPreview={disableMediaPreview}
/>
</div>
))
: null
: [1, 2, 3, 4].map((x) => (
<div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
</div>
))}
</SimpleGrid>
{pages.isSuccess && pages.data.length ? (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 12,
paddingBottom: 3,
}}
>
<div></div>
<Pagination total={pages.data?.length ?? 0} page={page} onChange={setPage} />
<Checkbox
label='Show non-media files'
checked={checked}
onChange={(event) => setChecked(event.currentTarget.checked)}
/>
</Box>
) : null}
</>
);
}

View File

@@ -0,0 +1,80 @@
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Title } from '@mantine/core';
import File from 'components/File';
import { PlusIcon } from 'components/icons';
import { usePaginatedFiles } from 'lib/queries/files';
import Link from 'next/link';
import { useState } from 'react';
import FilePagation from './FilePagation';
export default function Files({ disableMediaPreview }) {
const pages = usePaginatedFiles({ filter: 'media' });
const favoritePages = usePaginatedFiles({ favorite: 'media' });
const [favoritePage, setFavoritePage] = useState(1);
const updatePages = async (favorite) => {
pages.refetch();
if (favorite) {
favoritePages.refetch();
}
};
return (
<>
<Group mb='md'>
<Title>Files</Title>
<Link href='/dashboard/upload' passHref legacyBehavior>
<ActionIcon component='a' variant='filled' color='primary'>
<PlusIcon />
</ActionIcon>
</Link>
</Group>
{favoritePages.isSuccess && favoritePages.data.length ? (
<Accordion
variant='contained'
mb='sm'
styles={(t) => ({
content: { backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[7] : t.colors.gray[0] },
control: { backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[7] : t.colors.gray[0] },
})}
>
<Accordion.Item value='favorite'>
<Accordion.Control>Favorite Files</Accordion.Control>
<Accordion.Panel>
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{favoritePages.isSuccess && favoritePages.data.length
? favoritePages.data[favoritePage - 1 ?? 0].map((image) => (
<div key={image.id}>
<File
image={image}
updateImages={() => updatePages(true)}
disableMediaPreview={disableMediaPreview}
/>
</div>
))
: null}
</SimpleGrid>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingTop: 12,
paddingBottom: 3,
}}
>
<Pagination
total={favoritePages.data.length}
page={favoritePage}
onChange={setFavoritePage}
/>
</Box>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
) : null}
<FilePagation disableMediaPreview={disableMediaPreview} />
</>
);
}

View File

@@ -1,4 +1,18 @@
import { ActionIcon, Avatar, Button, Card, Group, Modal, Select, SimpleGrid, Skeleton, Stack, Switch, TextInput, Title } from '@mantine/core';
import {
ActionIcon,
Avatar,
Button,
Card,
Group,
Modal,
NumberInput,
Select,
SimpleGrid,
Skeleton,
Stack,
Title,
Tooltip,
} from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { useForm } from '@mantine/form';
import { useModals } from '@mantine/modals';
@@ -8,42 +22,42 @@ import MutedText from 'components/MutedText';
import useFetch from 'hooks/useFetch';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { expireText, relativeTime } from 'lib/utils/client';
const expires = [
'30m',
'1h',
'6h',
'12h',
'1d',
'3d',
'5d',
'7d',
'never',
];
const expires = ['30m', '1h', '6h', '12h', '1d', '3d', '5d', '7d', 'never'];
function CreateInviteModal({ open, setOpen, updateInvites }) {
const form = useForm({
initialValues: {
expires: '30m',
count: 1,
},
});
const onSubmit = async values => {
const onSubmit = async (values) => {
if (!expires.includes(values.expires)) return form.setFieldError('expires', 'Invalid expiration');
const expires_at = values.expires === 'never' ? null : new Date({
'30m': Date.now() + 30 * 60 * 1000,
'1h': Date.now() + 60 * 60 * 1000,
'6h': Date.now() + 6 * 60 * 60 * 1000,
'12h': Date.now() + 12 * 60 * 60 * 1000,
'1d': Date.now() + 24 * 60 * 60 * 1000,
'5d': Date.now() + 5 * 24 * 60 * 60 * 1000,
'7d': Date.now() + 7 * 24 * 60 * 60 * 1000,
}[values.expires]);
if (values.count < 1 || values.count > 100)
return form.setFieldError('count', 'Must be between 1 and 100');
const expires_at =
values.expires === 'never'
? null
: new Date(
{
'30m': Date.now() + 30 * 60 * 1000,
'1h': Date.now() + 60 * 60 * 1000,
'6h': Date.now() + 6 * 60 * 60 * 1000,
'12h': Date.now() + 12 * 60 * 60 * 1000,
'1d': Date.now() + 24 * 60 * 60 * 1000,
'5d': Date.now() + 5 * 24 * 60 * 60 * 1000,
'7d': Date.now() + 7 * 24 * 60 * 60 * 1000,
}[values.expires]
);
setOpen(false);
const res = await useFetch('/api/auth/invite', 'POST', {
expires_at,
count: values.count,
});
if (res.error) {
@@ -66,12 +80,8 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
};
return (
<Modal
opened={open}
onClose={() => setOpen(false)}
title={<Title>Create Invite</Title>}
>
<form onSubmit={form.onSubmit(v => onSubmit(v))}>
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>Create Invite</Title>}>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<Select
label='Expires'
id='expires'
@@ -89,7 +99,18 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
]}
/>
<Group position='right' mt={22}>
<NumberInput
label='Count'
id='count'
{...form.getInputProps('count')}
precision={0}
min={1}
stepHoldDelay={200}
stepHoldInterval={100}
parser={(v: string) => Number(v.replace(/[^\d]/g, ''))}
/>
<Group position='right' mt='md'>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button type='submit'>Create</Button>
</Group>
@@ -98,7 +119,7 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
);
}
export default function Users() {
export default function Uz2sers() {
const router = useRouter();
const modals = useModals();
const clipboard = useClipboard();
@@ -106,35 +127,36 @@ export default function Users() {
const [invites, setInvites] = useState([]);
const [open, setOpen] = useState(false);
const openDeleteModal = invite => modals.openConfirmModal({
title: `Delete ${invite.code}?`,
centered: true,
overlayBlur: 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}',
message: res.error,
icon: <CrossIcon />,
color: 'red',
});
} else {
showNotification({
title: `Deleted invite ${invite.code}`,
message: '',
icon: <DeleteIcon />,
color: 'green',
});
}
const openDeleteModal = (invite) =>
modals.openConfirmModal({
title: `Delete ${invite.code}?`,
centered: true,
overlayBlur: 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}',
message: res.error,
icon: <CrossIcon />,
color: 'red',
});
} else {
showNotification({
title: `Deleted invite ${invite.code}`,
message: '',
icon: <DeleteIcon />,
color: 'green',
});
}
updateInvites();
},
});
updateInvites();
},
});
const handleCopy = async invite => {
clipboard.copy(`${window.location.protocol}//${window.location.host}/invite/${invite.code}`);
const handleCopy = async (invite) => {
clipboard.copy(`${window.location.protocol}//${window.location.host}/auth/register?code=${invite.code}`);
showNotification({
title: 'Copied to clipboard',
message: '',
@@ -148,7 +170,7 @@ export default function Users() {
setInvites(us);
} else {
router.push('/dashboard');
};
}
};
useEffect(() => {
@@ -160,40 +182,49 @@ export default function Users() {
<CreateInviteModal open={open} setOpen={setOpen} updateInvites={updateInvites} />
<Group mb='md'>
<Title>Invites</Title>
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon /></ActionIcon>
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}>
<PlusIcon />
</ActionIcon>
</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>
<MutedText size='sm'>Created: {new Date(invite.created_at).toLocaleString()}</MutedText>
<MutedText size='sm'>Expires: {invite.expires_at ? new Date(invite.expires_at).toLocaleString() : 'Never'}</MutedText>
</Stack>
</Group>
<Group position='right'>
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
<CopyIcon />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
<DeleteIcon />
</ActionIcon>
</Group>
</Group>
</Card>
)) : [1, 2, 3].map(x => (
<Skeleton key={x} width='100%' height={100} radius='sm' />
))}
<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.created_at).toLocaleString()}>
<div>
<MutedText size='sm'>Created {relativeTime(new Date(invite.created_at))}</MutedText>
</div>
</Tooltip>
<Tooltip label={new Date(invite.expires_at).toLocaleString()}>
<div>
<MutedText size='sm'>{expireText(invite.expires_at)}</MutedText>
</div>
</Tooltip>
</Stack>
</Group>
<Group position='right'>
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
<CopyIcon />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
<DeleteIcon />
</ActionIcon>
</Group>
</Group>
</Card>
))
: [1, 2, 3].map((x) => <Skeleton key={x} width='100%' height={100} radius='sm' />)}
</SimpleGrid>
</>
);
}
}

View File

@@ -1,344 +0,0 @@
import { Box, Button, Card, ColorInput, Group, MultiSelect, Space, Text, TextInput, PasswordInput, Title, Tooltip, FileInput, Image } from '@mantine/core';
import { randomId, useInterval } from '@mantine/hooks';
import { useForm } from '@mantine/form';
import { useModals } from '@mantine/modals';
import { showNotification, updateNotification } from '@mantine/notifications';
import { CrossIcon, DeleteIcon, SettingsIcon } from 'components/icons';
import DownloadIcon from 'components/icons/DownloadIcon';
import Link from 'components/Link';
import { SmallTable } from 'components/SmallTable';
import useFetch from 'hooks/useFetch';
import { bytesToRead } from 'lib/clientUtils';
import { updateUser } from 'lib/redux/reducers/user';
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
import { useEffect, useState } from 'react';
import MutedText from 'components/MutedText';
function ExportDataTooltip({ children }) {
return <Tooltip position='top' color='' label='After clicking, if you have a lot of files the export can take a while to complete. A list of previous exports will be below to download.'>{children}</Tooltip>;
}
export default function Manage() {
const user = useStoreSelector(state => state.user);
const dispatch = useStoreDispatch();
const modals = useModals();
const [exports, setExports] = useState([]);
const [domains, setDomains] = useState(user.domains ?? []);
const [file, setFile] = useState<File>(null);
const [fileDataURL, setFileDataURL] = useState(user.avatar ?? null);
const getDataURL = (f: File): Promise<string> => {
return new Promise((res, rej) => {
const reader = new FileReader();
reader.addEventListener('load', () => {
res(reader.result as string);
});
reader.addEventListener('error', () => {
rej(reader.error);
});
reader.readAsDataURL(f);
});
};
const handleAvatarChange = async (file: File) => {
setFile(file);
setFileDataURL(await getDataURL(file));
};
const saveAvatar = async () => {
const dataURL = await getDataURL(file);
showNotification({
id: 'update-user',
title: 'Updating user...',
message: '',
loading: true,
autoClose: false,
});
const newUser = await useFetch('/api/user', 'PATCH', {
avatar: dataURL,
});
if (newUser.error) {
updateNotification({
id: 'update-user',
title: 'Couldn\'t save user',
message: newUser.error,
color: 'red',
icon: <CrossIcon />,
});
} else {
dispatch(updateUser(newUser));
updateNotification({
id: 'update-user',
title: 'Saved User',
message: '',
});
}
};
const genShareX = (withEmbed: boolean = false, withZws: boolean = false) => {
const config = {
Version: '13.2.1',
Name: 'Zipline',
DestinationType: 'ImageUploader, TextUploader',
RequestMethod: 'POST',
RequestURL: `${window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : '')}/api/upload`,
Headers: {
Authorization: user?.token,
...(withEmbed && { Embed: 'true' }),
...(withZws && { ZWS: 'true' }),
},
URL: '$json:files[0]$',
Body: 'MultipartFormData',
FileFormName: 'file',
};
const pseudoElement = document.createElement('a');
pseudoElement.setAttribute('href', 'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(config, null, '\t')));
pseudoElement.setAttribute('download', `zipline${withEmbed ? '_embed' : ''}${withZws ? '_zws' : ''}.sxcu`);
pseudoElement.style.display = 'none';
document.body.appendChild(pseudoElement);
pseudoElement.click();
pseudoElement.parentNode.removeChild(pseudoElement);
};
const form = useForm({
initialValues: {
username: user.username,
password: '',
embedTitle: user.embedTitle ?? '',
embedColor: user.embedColor,
embedSiteName: user.embedSiteName ?? '',
domains: user.domains ?? [],
},
});
const onSubmit = async values => {
const cleanUsername = values.username.trim();
const cleanPassword = values.password.trim();
const cleanEmbedTitle = values.embedTitle.trim();
const cleanEmbedColor = values.embedColor.trim();
const cleanEmbedSiteName = values.embedSiteName.trim();
if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
showNotification({
id: 'update-user',
title: 'Updating user...',
message: '',
loading: true,
autoClose: false,
});
const data = {
username: cleanUsername,
password: cleanPassword === '' ? null : cleanPassword,
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor,
embedSiteName: cleanEmbedSiteName === '' ? null : cleanEmbedSiteName,
domains,
};
const newUser = await useFetch('/api/user', 'PATCH', data);
if (newUser.error) {
if (newUser.invalidDomains) {
updateNotification({
id: 'update-user',
message: <>
<Text mt='xs'>The following domains are invalid:</Text>
{newUser.invalidDomains.map(err => (
<>
<Text color='gray' key={randomId()}>{err.domain}: {err.reason}</Text>
<Space h='md' />
</>
))}
</>,
color: 'red',
icon: <CrossIcon />,
});
}
updateNotification({
id: 'update-user',
title: 'Couldn\'t save user',
message: newUser.error,
color: 'red',
icon: <CrossIcon />,
});
} else {
dispatch(updateUser(newUser));
updateNotification({
id: 'update-user',
title: 'Saved User',
message: '',
});
}
};
const exportData = async () => {
const res = await useFetch('/api/user/export', 'POST');
if (res.url) {
showNotification({
title: 'Export started...',
loading: true,
message: 'If you have a lot of files, the export may take a while. The list of exports will be updated every 30s.',
});
}
};
const getExports = async () => {
const res = await useFetch('/api/user/export');
setExports(res.exports.map(s => ({
date: new Date(Number(s.name.split('_')[3].slice(0, -4))),
size: s.size,
full: s.name,
})).sort((a, b) => a.date.getTime() - b.date.getTime()));
};
const handleDelete = async () => {
const res = await useFetch('/api/user/files', 'DELETE', {
all: true,
});
if (!res.count) {
showNotification({
title: 'Couldn\'t delete files',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
} else {
showNotification({
title: 'Deleted files',
message: `${res.count} files deleted`,
color: 'green',
icon: <DeleteIcon />,
});
}
};
const openDeleteModal = () => modals.openConfirmModal({
title: 'Are you sure you want to delete all of your images?',
closeOnConfirm: false,
labels: { confirm: 'Yes', cancel: 'No' },
onConfirm: () => {
modals.openConfirmModal({
title: 'Are you really sure?',
labels: { confirm: 'Yes', cancel: 'No' },
onConfirm: () => {
handleDelete();
modals.closeAll();
},
onCancel: () => {
modals.closeAll();
},
});
},
});
const interval = useInterval(() => getExports(), 30000);
useEffect(() => {
getExports();
interval.start();
}, []);
return (
<>
<Title>Manage User</Title>
<MutedText size='md'>Want to use variables in embed text? Visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for variables</MutedText>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput id='username' label='Username' {...form.getInputProps('username')} />
<PasswordInput id='password' label='Password' description='Leave blank to keep your old password' {...form.getInputProps('password')} />
<TextInput id='embedTitle' label='Embed Title' {...form.getInputProps('embedTitle')} />
<ColorInput id='embedColor' label='Embed Color' {...form.getInputProps('embedColor')} />
<TextInput id='embedSiteName' label='Embed Site Name' {...form.getInputProps('embedSiteName')} />
<MultiSelect
id='domains'
label='Domains'
data={domains}
placeholder='Leave blank if you dont want random domain selection.'
creatable
searchable
clearable
getCreateLabel={query => `Add ${query}`}
onCreate={query => setDomains((current) => [...current, query])}
{...form.getInputProps('domains')}
/>
<Group position='right' mt='md'>
<Button
type='submit'
>Save User</Button>
</Group>
</form>
<Box mb='md'>
<Title>Avatar</Title>
<FileInput id='file' description='Add a custom avatar or leave blank for none' accept='image/png,image/jpeg,image/gif' value={file} onChange={handleAvatarChange} />
<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,
},
})}
size='xl'
p='sm'
>
{user.username}
</Button>
</Card>
<Group position='right' mt='md'>
<Button onClick={() => { setFile(null); setFileDataURL(null); }}>Reset</Button>
<Button onClick={saveAvatar} >Save Avatar</Button>
</Group>
</Box>
<Box mb='md'>
<Title>Manage Data</Title>
<MutedText size='md'>Delete, or export your data into a zip file.</MutedText>
</Box>
<Group>
<Button onClick={openDeleteModal} rightIcon={<DeleteIcon />}>Delete All Data</Button>
<ExportDataTooltip><Button onClick={exportData} rightIcon={<DownloadIcon />}>Export Data</Button></ExportDataTooltip>
</Group>
<Card mt={22}>
{exports && exports.length ? (
<SmallTable
columns={[
{ id: 'name', name: 'Name' },
{ id: 'date', name: 'Date' },
{ id: 'size', name: 'Size' },
]}
rows={exports ? exports.map((x, i) => ({
name: <Link href={'/api/user/export?name=' + x.full}>Export {i + 1}</Link>,
date: x.date.toLocaleString(),
size: bytesToRead(x.size),
})) : []} />
) : (
<Text>No exports yet</Text>
)}
</Card>
<Title my='md'>ShareX Config</Title>
<Group>
<Button onClick={() => genShareX(false)} rightIcon={<DownloadIcon />}>ShareX Config</Button>
<Button onClick={() => genShareX(true)} rightIcon={<DownloadIcon />}>ShareX Config with Embed</Button>
<Button onClick={() => genShareX(false, true)} rightIcon={<DownloadIcon />}>ShareX Config with ZWS</Button>
</Group>
</>
);
}

View File

@@ -0,0 +1,75 @@
import { GeneratorModal } from './GeneratorModal';
export default function Flameshot({ user, open, setOpen }) {
const onSubmit = (values) => {
const curl = [
'curl',
'-H',
'"Content-Type: multipart/form-data"',
'-H',
`"authorization: ${user?.token}"`,
'-F',
'file=@/tmp/ss.png',
`${
window.location.protocol +
'//' +
window.location.hostname +
(window.location.port ? ':' + window.location.port : '')
}/api/upload`,
];
const extraHeaders = {};
if (values.format !== 'RANDOM') {
extraHeaders['Format'] = values.format;
} else {
delete extraHeaders['Format'];
}
if (values.imageCompression !== 0) {
extraHeaders['Image-Compression-Percent'] = values.imageCompression;
} else {
delete extraHeaders['Image-Compression-Percent'];
}
if (values.zeroWidthSpace) {
extraHeaders['Zws'] = 'true';
} else {
delete extraHeaders['Zws'];
}
if (values.embed) {
extraHeaders['Embed'] = 'true';
} else {
delete extraHeaders['Embed'];
}
for (const [key, value] of Object.entries(extraHeaders)) {
curl.push('-H');
curl.push(`"${key}: ${value}"`);
}
const shell = `#!/bin/bash
flameshot gui -r > /tmp/ss.png;
${curl.join(' ')} | jq -r '.files[0]' | tr -d '\n' | xsel -ib;
`;
const pseudoElement = document.createElement('a');
pseudoElement.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(shell));
pseudoElement.setAttribute('download', 'zipline.sh');
pseudoElement.style.display = 'none';
document.body.appendChild(pseudoElement);
pseudoElement.click();
pseudoElement.parentNode.removeChild(pseudoElement);
};
return (
<GeneratorModal
opened={open}
onClose={() => setOpen(false)}
title='Flameshot'
desc='To use this script, you need Flameshot, curl, jq, and xsel installed. This script is intended for use on Linux only.'
onSubmit={onSubmit}
/>
);
}

View File

@@ -0,0 +1,61 @@
import { Modal, Select, NumberInput, Group, Checkbox, Button, Title, Text } from '@mantine/core';
import { useForm } from '@mantine/form';
import { DownloadIcon } from 'components/icons';
export function GeneratorModal({ opened, onClose, title, onSubmit, ...other }) {
const form = useForm({
initialValues: {
format: 'RANDOM',
imageCompression: 0,
zeroWidthSpace: false,
embed: false,
},
});
return (
<Modal opened={opened} onClose={onClose} title={<Title order={3}>{title}</Title>} size='lg'>
{other.desc && <Text mb='md'>{other.desc}</Text>}
<form onSubmit={form.onSubmit((values) => onSubmit(values))}>
<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)' },
]}
id='format'
{...form.getInputProps('format')}
/>
<NumberInput
label={"Image Compression (leave at 0 if you don't want to compress)"}
max={100}
min={0}
mt='md'
id='imageCompression'
{...form.getInputProps('imageCompression')}
/>
<Group grow mt='md'>
<Checkbox
label='Zero Width Space'
id='zeroWidthSpace'
{...form.getInputProps('zeroWidthSpace', { type: 'checkbox' })}
/>
<Checkbox label='Embed' id='embed' {...form.getInputProps('embed', { type: 'checkbox' })} />
</Group>
<Group grow>
<Button mt='md' onClick={form.reset}>
Reset
</Button>
<Button mt='md' rightIcon={<DownloadIcon />} type='submit'>
Download
</Button>
</Group>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,70 @@
import { useState } from 'react';
import { GeneratorModal } from './GeneratorModal';
export default function ShareX({ user, open, setOpen }) {
const [config, setConfig] = useState({
Version: '13.2.1',
Name: 'Zipline',
DestinationType: 'ImageUploader, TextUploader',
RequestMethod: 'POST',
RequestURL: `${
window.location.protocol +
'//' +
window.location.hostname +
(window.location.port ? ':' + window.location.port : '')
}/api/upload`,
Headers: {
Authorization: user?.token,
},
URL: '$json:files[0]$',
Body: 'MultipartFormData',
FileFormName: 'file',
});
const onSubmit = (values) => {
if (values.format !== 'RANDOM') {
config.Headers['Format'] = values.format;
setConfig(config);
} else {
delete config.Headers['Format'];
setConfig(config);
}
if (values.imageCompression !== 0) {
config.Headers['Image-Compression-Percent'] = values.imageCompression;
setConfig(config);
} else {
delete config.Headers['Image-Compression-Percent'];
setConfig(config);
}
if (values.zeroWidthSpace) {
config.Headers['Zws'] = 'true';
setConfig(config);
} else {
delete config.Headers['Zws'];
setConfig(config);
}
if (values.embed) {
config.Headers['Embed'] = 'true';
setConfig(config);
} else {
delete config.Headers['Embed'];
setConfig(config);
}
const pseudoElement = document.createElement('a');
pseudoElement.setAttribute(
'href',
'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(config, null, '\t'))
);
pseudoElement.setAttribute('download', 'zipline.sxcu');
pseudoElement.style.display = 'none';
document.body.appendChild(pseudoElement);
pseudoElement.click();
pseudoElement.parentNode.removeChild(pseudoElement);
};
return <GeneratorModal opened={open} onClose={() => setOpen(false)} title='ShareX' onSubmit={onSubmit} />;
}

View File

@@ -0,0 +1,521 @@
import {
Anchor,
Box,
Button,
Card,
ColorInput,
FileInput,
Group,
Image,
PasswordInput,
Space,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { randomId, useInterval } 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';
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 { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
import Flameshot from './Flameshot';
import ShareX from './ShareX';
function ExportDataTooltip({ children }) {
return (
<Tooltip
position='top'
color=''
label='After clicking, if you have a lot of files the export can take a while to complete. A list of previous exports will be below to download.'
>
{children}
</Tooltip>
);
}
export default function Manage({ oauth_registration, oauth_providers: raw_oauth_providers }) {
const oauth_providers = JSON.parse(raw_oauth_providers);
const icons = {
Discord: DiscordIcon,
GitHub: GitHubIcon,
Google: GoogleIcon,
};
for (const provider of oauth_providers) {
provider.Icon = icons[provider.name];
}
const [user, setUser] = useRecoilState(userSelector);
const modals = useModals();
const [shareXOpen, setShareXOpen] = useState(false);
const [flameshotOpen, setFlameshotOpen] = useState(false);
const [exports, setExports] = useState([]);
const [file, setFile] = useState<File>(null);
const [fileDataURL, setFileDataURL] = useState(user.avatar ?? null);
const getDataURL = (f: File): Promise<string> => {
return new Promise((res, rej) => {
const reader = new FileReader();
reader.addEventListener('load', () => {
res(reader.result as string);
});
reader.addEventListener('error', () => {
rej(reader.error);
});
reader.readAsDataURL(f);
});
};
const handleAvatarChange = async (file: File) => {
setFile(file);
setFileDataURL(await getDataURL(file));
};
const saveAvatar = async () => {
const dataURL = await getDataURL(file);
showNotification({
id: 'update-user',
title: 'Updating user...',
message: '',
loading: true,
autoClose: false,
});
const newUser = await useFetch('/api/user', 'PATCH', {
avatar: dataURL,
});
if (newUser.error) {
updateNotification({
id: 'update-user',
title: "Couldn't save user",
message: newUser.error,
color: 'red',
icon: <CrossIcon />,
});
} else {
setUser(newUser);
updateNotification({
id: 'update-user',
title: 'Saved User',
message: '',
});
}
};
const form = useForm({
initialValues: {
username: user.username,
password: '',
embedTitle: user.embedTitle ?? '',
embedColor: user.embedColor,
embedSiteName: user.embedSiteName ?? '',
domains: user.domains.join(','),
},
});
const onSubmit = async (values) => {
const cleanUsername = values.username.trim();
const cleanPassword = values.password.trim();
const cleanEmbedTitle = values.embedTitle.trim();
const cleanEmbedColor = values.embedColor.trim();
const cleanEmbedSiteName = values.embedSiteName.trim();
if (cleanUsername === '') return form.setFieldError('username', "Username can't be nothing");
showNotification({
id: 'update-user',
title: 'Updating user...',
message: '',
loading: true,
autoClose: false,
});
const data = {
username: cleanUsername,
password: cleanPassword === '' ? null : cleanPassword,
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor,
embedSiteName: cleanEmbedSiteName === '' ? null : cleanEmbedSiteName,
domains: values.domains
.split(/\s?,\s?/)
.map((x) => x.trim())
.filter((x) => x !== ''),
};
const newUser = await useFetch('/api/user', 'PATCH', data);
if (newUser.error) {
if (newUser.invalidDomains) {
updateNotification({
id: 'update-user',
message: (
<>
<Text mt='xs'>The following domains are invalid:</Text>
{newUser.invalidDomains.map((err) => (
<>
<Text color='gray' key={randomId()}>
{err.domain}: {err.reason}
</Text>
<Space h='md' />
</>
))}
</>
),
color: 'red',
icon: <CrossIcon />,
});
}
updateNotification({
id: 'update-user',
title: "Couldn't save user",
message: newUser.error,
color: 'red',
icon: <CrossIcon />,
});
} else {
setUser(newUser);
updateNotification({
id: 'update-user',
title: 'Saved User',
message: '',
});
}
};
const exportData = async () => {
const res = await useFetch('/api/user/export', 'POST');
if (res.url) {
showNotification({
title: 'Export started...',
loading: true,
message:
'If you have a lot of files, the export may take a while. The list of exports will be updated every 30s.',
});
} else {
showNotification({
title: 'Error exporting data',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
}
};
const getExports = async () => {
const res = await useFetch('/api/user/export');
setExports(
res.exports
?.map((s) => ({
date: new Date(Number(s.name.split('_')[3].slice(0, -4))),
size: s.size,
full: s.name,
}))
.sort((a, b) => a.date.getTime() - b.date.getTime())
);
};
const handleDelete = async () => {
const res = await useFetch('/api/user/files', 'DELETE', {
all: true,
});
if (!res.count) {
showNotification({
title: "Couldn't delete files",
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
} else {
showNotification({
title: 'Deleted files',
message: `${res.count} files deleted`,
color: 'green',
icon: <DeleteIcon />,
});
}
};
const openDeleteModal = () =>
modals.openConfirmModal({
title: 'Are you sure you want to delete all of your files?',
closeOnConfirm: false,
labels: { confirm: 'Yes', cancel: 'No' },
onConfirm: () => {
modals.openConfirmModal({
title: 'Are you really sure?',
labels: { confirm: 'Yes', cancel: 'No' },
onConfirm: () => {
handleDelete();
modals.closeAll();
},
onCancel: () => {
modals.closeAll();
},
});
},
});
const forceUpdateStats = async () => {
const res = await useFetch('/api/stats', 'POST');
if (res.error) {
showNotification({
title: 'Error updating stats',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
} else {
showNotification({
title: 'Updated stats',
message: '',
color: 'green',
icon: <CheckIcon />,
});
}
};
const handleOauthUnlink = async (provider) => {
const res = await useFetch('/api/auth/oauth', 'DELETE', {
provider,
});
if (res.error) {
showNotification({
title: 'Error while unlinking from OAuth',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
} else {
setUser(res);
showNotification({
title: `Unlinked from ${provider[0] + provider.slice(1).toLowerCase()}`,
message: '',
color: 'green',
icon: <CheckIcon />,
});
}
};
const interval = useInterval(() => getExports(), 30000);
useEffect(() => {
getExports();
interval.start();
}, []);
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
</MutedText>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput id='username' label='Username' my='sm' {...form.getInputProps('username')} />
<PasswordInput
id='password'
label='Password'
description='Leave blank to keep your old password'
my='sm'
{...form.getInputProps('password')}
/>
<TextInput id='embedTitle' label='Embed Title' my='sm' {...form.getInputProps('embedTitle')} />
<ColorInput id='embedColor' label='Embed Color' my='sm' {...form.getInputProps('embedColor')} />
<TextInput
id='embedSiteName'
label='Embed Site Name'
my='sm'
{...form.getInputProps('embedSiteName')}
/>
<TextInput
id='domains'
label='Domains'
description='A list of domains separated by commas. These domains will be used to randomly output a domain when uploading. This is optional.'
placeholder='https://example.com, https://example2.com'
my='sm'
{...form.getInputProps('domains')}
/>
<Group position='right' mt='md'>
<Button type='submit'>Save User</Button>
</Group>
</form>
{oauth_registration && (
<Box my='md'>
<Title>OAuth</Title>
<MutedText size='md'>Link your account with an OAuth provider.</MutedText>
<Group>
{oauth_providers
.filter(
(x) =>
!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>
))}
{user?.oauth?.map(({ provider }, i) => (
<Button
key={i}
onClick={() => handleOauthUnlink(provider)}
size='lg'
leftIcon={<TrashIcon />}
my='sm'
color='red'
>
Unlink account with {capitalize(provider)}
</Button>
))}
</Group>
</Box>
)}
<Box my='md'>
<Title>Avatar</Title>
<FileInput
placeholder='Click to upload a file'
id='file'
description='Add a custom avatar or leave blank for none'
accept='image/png,image/jpeg,image/gif'
value={file}
onChange={handleAvatarChange}
/>
<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,
},
})}
size='xl'
p='sm'
>
{user.username}
</Button>
</Card>
<Group position='right' mt='md'>
<Button
onClick={() => {
setFile(null);
setFileDataURL(null);
}}
color='red'
>
Reset
</Button>
<Button onClick={saveAvatar}>Save Avatar</Button>
</Group>
</Box>
<Box mb='md'>
<Title>Manage Data</Title>
<MutedText size='md'>Delete, or export your data into a zip file.</MutedText>
</Box>
<Group>
<Button onClick={openDeleteModal} rightIcon={<DeleteIcon />} color='red'>
Delete All Data
</Button>
<ExportDataTooltip>
<Button onClick={exportData} rightIcon={<DownloadIcon />}>
Export Data
</Button>
</ExportDataTooltip>
<Button onClick={getExports} rightIcon={<RefreshIcon />}>
Refresh
</Button>
</Group>
<Card mt='md'>
{exports && exports.length ? (
<SmallTable
columns={[
{ id: 'name', name: 'Name' },
{ id: 'date', name: 'Date' },
{ id: 'size', name: 'Size' },
]}
rows={
exports
? exports.map((x, i) => ({
name: (
<Anchor target='_blank' href={'/api/user/export?name=' + x.full}>
Export {i + 1}
</Anchor>
),
date: x.date.toLocaleString(),
size: bytesToHuman(x.size),
}))
: []
}
/>
) : (
<Text>No exports yet</Text>
)}
</Card>
{user.administrator && (
<Box mt='md'>
<Title>Server</Title>
<Group>
<Button size='md' onClick={forceUpdateStats} color='red' rightIcon={<RefreshIcon />}>
Force Update Stats
</Button>
</Group>
</Box>
)}
<Title my='md'>Uploaders</Title>
<Group>
<Button size='xl' onClick={() => setShareXOpen(true)} rightIcon={<ShareXIcon />}>
Generate ShareX Config
</Button>
<Button size='xl' onClick={() => setFlameshotOpen(true)} rightIcon={<FlameshotIcon />}>
Generate Flameshot Script
</Button>
</Group>
<ShareX user={user} open={shareXOpen} setOpen={setShareXOpen} />
<Flameshot user={user} open={flameshotOpen} setOpen={setFlameshotOpen} />
</>
);
}

View File

@@ -1,66 +0,0 @@
import { SimpleGrid, Skeleton, Title } from '@mantine/core';
import Card from 'components/Card';
import MutedText from 'components/MutedText';
import { SmallTable } from 'components/SmallTable';
import { bytesToRead } from 'lib/clientUtils';
import useFetch from 'lib/hooks/useFetch';
import { useEffect, useState } from 'react';
export default function Stats() {
const [stats, setStats] = useState(null);
const update = async () => {
const stts = await useFetch('/api/stats');
setStats(stts);
};
useEffect(() => {
update();
}, []);
return (
<>
<Title mb='md'>Stats</Title>
<SimpleGrid
cols={3}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
<Card name='Size' sx={{ height: '100%' }}>
<MutedText>{stats ? stats.size : <Skeleton height={8} />}</MutedText>
<Title order={2}>Average Size</Title>
<MutedText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</MutedText>
</Card>
<Card name='Images' sx={{ height: '100%' }}>
<MutedText>{stats ? stats.count : <Skeleton height={8} />}</MutedText>
<Title order={2}>Views</Title>
<MutedText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</MutedText>
</Card>
<Card name='Users' sx={{ height: '100%' }}>
<MutedText>{stats ? stats.count_users : <Skeleton height={8} />}</MutedText>
</Card>
</SimpleGrid>
{stats && stats.count_by_user.length ? (
<Card name='Files per User' mt={22}>
<SmallTable
columns={[
{ id: 'username', name: 'Name' },
{ id: 'count', name: 'Files' },
]}
rows={stats ? stats.count_by_user : []} />
</Card>
) : null}
<Card name='Types' mt={22}>
<SmallTable
columns={[
{ id: 'mimetype', name: 'Type' },
{ id: 'count', name: 'Count' },
]}
rows={stats ? stats.types_count : []} />
</Card>
</>
);
}

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