mirror of
https://github.com/diced/zipline.git
synced 2025-12-10 06:40:44 -08:00
Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1f281d8b4 | ||
|
|
d2f3999cf1 | ||
|
|
87fc9f2fb9 | ||
|
|
8c9064fd93 | ||
|
|
561849ae5b | ||
|
|
0847802ce4 | ||
|
|
d5a8b3f1fb | ||
|
|
e6cebd8c46 | ||
|
|
f2be036bac | ||
|
|
f14448d40d | ||
|
|
bf719808f2 | ||
|
|
9dd82c91d7 | ||
|
|
535f84064a | ||
|
|
0c0a55d766 | ||
|
|
6e3ee29eb4 | ||
|
|
6a7a5dc7a3 | ||
|
|
e78d2d79d0 | ||
|
|
451027eaf3 | ||
|
|
e4491610fb | ||
|
|
f30e10f235 | ||
|
|
f9249b1380 | ||
|
|
3df94526b0 | ||
|
|
b30b7b1bd3 | ||
|
|
a9defd67d6 | ||
|
|
68d346e69d | ||
|
|
e2fd27cbba | ||
|
|
4c0532006c | ||
|
|
7ac574b230 | ||
|
|
7eb855de8f | ||
|
|
d5984f4141 | ||
|
|
b7c0c85639 | ||
|
|
84ba166aea | ||
|
|
bd79858681 | ||
|
|
0f10fa3991 | ||
|
|
74b1799d21 | ||
|
|
4552643ff8 | ||
|
|
d432b388f6 | ||
|
|
a8475602c7 | ||
|
|
f58d33af9e | ||
|
|
0150ea5e70 | ||
|
|
3bf43f1606 | ||
|
|
b8729a6ec7 | ||
|
|
1f44aa7e85 | ||
|
|
2bd5352fc5 | ||
|
|
a90130e8bf | ||
|
|
642e8796f0 | ||
|
|
615cbddc89 | ||
|
|
4ef82bdff4 | ||
|
|
dafde04c2c | ||
|
|
1be61b8d89 | ||
|
|
c3215c7425 | ||
|
|
af0cd26ea0 | ||
|
|
cb7dacd089 | ||
|
|
8c04971094 | ||
|
|
3a4802f09a | ||
|
|
d78db306c5 | ||
|
|
3f8790ece1 | ||
|
|
f9e6158144 | ||
|
|
05de3fed15 | ||
|
|
38cba9cb39 | ||
|
|
a4af980e11 | ||
|
|
940b844857 | ||
|
|
41b766216e | ||
|
|
402987baba | ||
|
|
3cb08c73d3 | ||
|
|
4cb92a7257 | ||
|
|
a095768eae | ||
|
|
1a5925d7e8 | ||
|
|
9147847710 | ||
|
|
05fe8bcaca | ||
|
|
b0c3c6f45a | ||
|
|
0f641aa852 | ||
|
|
2651bbe50c | ||
|
|
d31371eb6c | ||
|
|
ec0e7e5ec7 | ||
|
|
feb75a8a42 | ||
|
|
d4369d2503 | ||
|
|
d236589644 | ||
|
|
8044b7f623 | ||
|
|
9f0697dd34 | ||
|
|
78a6f3122d | ||
|
|
b460da74dd | ||
|
|
75a8bb7962 | ||
|
|
9ac876e30a | ||
|
|
26cb4ea034 | ||
|
|
0d65ee1a32 | ||
|
|
4a753376b7 | ||
|
|
dc926e9f5a | ||
|
|
722372c7f6 | ||
|
|
4589c6ee0a | ||
|
|
67ff93e640 | ||
|
|
bd055d704b | ||
|
|
2e8bee931c | ||
|
|
a454a4f4a8 | ||
|
|
45541a3cdd | ||
|
|
1d42d922bd | ||
|
|
4f631fbd0e | ||
|
|
e911db4c1a |
@@ -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
|
||||
|
||||
@@ -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
14
.gitattributes
vendored
Normal 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
45
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal 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
12
.github/ISSUE_TEMPLATE/suggest.yml
vendored
Normal 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
|
||||
18
.github/workflows/build.yml
vendored
18
.github/workflows/build.yml
vendored
@@ -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'
|
||||
|
||||
19
.github/workflows/docker-release.yml
vendored
19
.github/workflows/docker-release.yml
vendored
@@ -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
|
||||
|
||||
18
.github/workflows/docker.yml
vendored
18
.github/workflows/docker.yml
vendored
@@ -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
|
||||
|
||||
5
.prettierrc.json
Normal file
5
.prettierrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 110
|
||||
}
|
||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"files.eol": "\n",
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
786
.yarn/releases/yarn-3.2.1.cjs
vendored
786
.yarn/releases/yarn-3.2.1.cjs
vendored
File diff suppressed because one or more lines are too long
801
.yarn/releases/yarn-3.2.4.cjs
vendored
Executable file
801
.yarn/releases/yarn-3.2.4.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
@@ -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
23
CONTRIBUTING.md
Normal 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.
|
||||
16
Dockerfile
16
Dockerfile
@@ -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"]
|
||||
63
README.md
63
README.md
@@ -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!
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://discord.gg/EAhCRfGxCF)
|
||||

|
||||

|
||||

|
||||
[](https://discord.gg/EAhCRfGxCF)
|
||||
|
||||

|
||||
[](https://github.com/diced/zipline/pkgs/container/zipline/?tag=trunk)
|
||||
[](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)
|
||||
|
||||

|
||||

|
||||

|
||||
</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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -34,4 +34,4 @@ services:
|
||||
- 'postgres'
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
pg_data:
|
||||
|
||||
@@ -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
9680
mimes.json
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
105
package.json
105
package.json
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "oauthAccessToken" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "superAdmin" BOOLEAN NOT NULL DEFAULT false;
|
||||
2
prisma/migrations/20221028005627_max_views/migration.sql
Normal file
2
prisma/migrations/20221028005627_max_views/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" ADD COLUMN "maxViews" INTEGER;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Url" ADD COLUMN "maxViews" INTEGER;
|
||||
31
prisma/migrations/20221030222208_oauth_reform/migration.sql
Normal file
31
prisma/migrations/20221030222208_oauth_reform/migration.sql
Normal 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;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "OAuth_provider_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "OAuth" ADD COLUMN "refresh" TEXT;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "OauthProviders" ADD VALUE 'GOOGLE';
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
71
src/components/StatCard.tsx
Normal file
71
src/components/StatCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'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} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { Activity } from 'react-feather';
|
||||
|
||||
export default function ActivityIcon({ ...props }) {
|
||||
return <Activity size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { Disc } from 'react-feather';
|
||||
|
||||
export default function AudioIcon({ ...props }) {
|
||||
return <Disc size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { Calendar } from 'react-feather';
|
||||
|
||||
export default function CalendarIcon({ ...props }) {
|
||||
return <Calendar size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { Check } from 'react-feather';
|
||||
|
||||
export default function CheckIcon({ ...props }) {
|
||||
return <Check size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { Clock } from 'react-feather';
|
||||
|
||||
export default function ClockIcon({ ...props }) {
|
||||
return <Clock size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { Copy } from 'react-feather';
|
||||
|
||||
export default function CopyIcon({ ...props }) {
|
||||
return <Copy size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { X } from 'react-feather';
|
||||
|
||||
export default function CrossIcon({ ...props }) {
|
||||
return <X size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { Delete } from 'react-feather';
|
||||
|
||||
export default function DeleteIcon({ ...props }) {
|
||||
return <Delete size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
21
src/components/icons/DiscordIcon.tsx
Normal file
21
src/components/icons/DiscordIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,4 +2,4 @@ import { Download } from 'react-feather';
|
||||
|
||||
export default function DownloadIcon({ ...props }) {
|
||||
return <Download size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { LogIn } from 'react-feather';
|
||||
|
||||
export default function EnterIcon({ ...props }) {
|
||||
return <LogIn size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
5
src/components/icons/ExternalLinkIcon.tsx
Normal file
5
src/components/icons/ExternalLinkIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ExternalLink } from 'react-feather';
|
||||
|
||||
export default function ExternalLinkIcon({ ...props }) {
|
||||
return <ExternalLink size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/EyeIcon.tsx
Normal file
5
src/components/icons/EyeIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Eye } from 'react-feather';
|
||||
|
||||
export default function EyeIcon({ ...props }) {
|
||||
return <Eye size={15} {...props} />;
|
||||
}
|
||||
@@ -2,4 +2,4 @@ import { File } from 'react-feather';
|
||||
|
||||
export default function FileIcon({ ...props }) {
|
||||
return <File size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
15
src/components/icons/FlameshotIcon.tsx
Normal file
15
src/components/icons/FlameshotIcon.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
17
src/components/icons/GitHubIcon.tsx
Normal file
17
src/components/icons/GitHubIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/components/icons/GoogleIcon.tsx
Normal file
15
src/components/icons/GoogleIcon.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,4 +2,4 @@ import { Hash } from 'react-feather';
|
||||
|
||||
export default function HashIcon({ ...props }) {
|
||||
return <Hash size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { Home } from 'react-feather';
|
||||
|
||||
export default function HomeIcon({ ...props }) {
|
||||
return <Home size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { Image as FeatherImage } from 'react-feather';
|
||||
|
||||
export default function ImageIcon({ ...props }) {
|
||||
return <FeatherImage size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { Link } from 'react-feather';
|
||||
|
||||
export default function LinkIcon({ ...props }) {
|
||||
return <Link size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { LogOut } from 'react-feather';
|
||||
|
||||
export default function LogoutIcon({ ...props }) {
|
||||
return <LogOut size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { Edit2 } from 'react-feather';
|
||||
|
||||
export default function PencilIcon({ ...props }) {
|
||||
return <Edit2 size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { Play } from 'react-feather';
|
||||
|
||||
export default function PlayIcon({ ...props }) {
|
||||
return <Play size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { Plus } from 'react-feather';
|
||||
|
||||
export default function PlusIcon({ ...props }) {
|
||||
return <Plus size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
5
src/components/icons/RefreshIcon.tsx
Normal file
5
src/components/icons/RefreshIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { RefreshCw } from 'react-feather';
|
||||
|
||||
export default function RefreshIcon({ ...props }) {
|
||||
return <RefreshCw size={15} {...props} />;
|
||||
}
|
||||
@@ -2,4 +2,4 @@ import { Settings } from 'react-feather';
|
||||
|
||||
export default function SettingsIcon({ ...props }) {
|
||||
return <Settings size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
9
src/components/icons/ShareXIcon.tsx
Normal file
9
src/components/icons/ShareXIcon.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
@@ -2,4 +2,4 @@ import { Star } from 'react-feather';
|
||||
|
||||
export default function StarIcon({ ...props }) {
|
||||
return <Star size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { Tag } from 'react-feather';
|
||||
|
||||
export default function TagIcon({ ...props }) {
|
||||
return <Tag size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
5
src/components/icons/TrashIcon.tsx
Normal file
5
src/components/icons/TrashIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Trash2 } from 'react-feather';
|
||||
|
||||
export default function TrashIcon({ ...props }) {
|
||||
return <Trash2 size={15} {...props} />;
|
||||
}
|
||||
@@ -2,4 +2,4 @@ import { Type } from 'react-feather';
|
||||
|
||||
export default function TypeIcon({ ...props }) {
|
||||
return <Type size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { Upload } from 'react-feather';
|
||||
|
||||
export default function UploadIcon({ ...props }) {
|
||||
return <Upload size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { User } from 'react-feather';
|
||||
|
||||
export default function UserIcon({ ...props }) {
|
||||
return <User size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { Video } from 'react-feather';
|
||||
|
||||
export default function VideoIcon({ ...props }) {
|
||||
return <Video size={15} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
54
src/components/pages/Dashboard/RecentFiles.tsx
Normal file
54
src/components/pages/Dashboard/RecentFiles.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
src/components/pages/Dashboard/StatCards.tsx
Normal file
69
src/components/pages/Dashboard/StatCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
src/components/pages/Dashboard/index.tsx
Normal file
153
src/components/pages/Dashboard/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
72
src/components/pages/Files/FilePagation.tsx
Normal file
72
src/components/pages/Files/FilePagation.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
80
src/components/pages/Files/index.tsx
Normal file
80
src/components/pages/Files/index.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
75
src/components/pages/Manage/Flameshot.tsx
Normal file
75
src/components/pages/Manage/Flameshot.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
61
src/components/pages/Manage/GeneratorModal.tsx
Normal file
61
src/components/pages/Manage/GeneratorModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/components/pages/Manage/ShareX.tsx
Normal file
70
src/components/pages/Manage/ShareX.tsx
Normal 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} />;
|
||||
}
|
||||
521
src/components/pages/Manage/index.tsx
Normal file
521
src/components/pages/Manage/index.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user