mirror of
https://github.com/diced/zipline.git
synced 2025-12-25 04:15:41 -08:00
Compare commits
148 Commits
v3.6.4
...
feature/of
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ac81c887e | ||
|
|
5178f762eb | ||
|
|
bcc2d673dd | ||
|
|
bf40fa9cd2 | ||
|
|
bc58c1b56e | ||
|
|
c57a6e1700 | ||
|
|
8649a489d8 | ||
|
|
40f29907c7 | ||
|
|
34005ece43 | ||
|
|
8e6fc1e8a3 | ||
|
|
065f44b145 | ||
|
|
e707685da3 | ||
|
|
e5a07f568d | ||
|
|
a728d71da1 | ||
|
|
91e468791e | ||
|
|
169a2ea562 | ||
|
|
f9060f8ae7 | ||
|
|
d379bf8b1c | ||
|
|
67b71ceffe | ||
|
|
eb6929b889 | ||
|
|
d7299f8220 | ||
|
|
1ed267ad94 | ||
|
|
40a0cce3e8 | ||
|
|
556aafaad3 | ||
|
|
fdc7901eff | ||
|
|
9632399f5d | ||
|
|
cc8a5411ab | ||
|
|
12bb804e6a | ||
|
|
3a27f31a03 | ||
|
|
37e7ad840c | ||
|
|
c57a1ea326 | ||
|
|
12d5d5f08f | ||
|
|
e7cf44e8e9 | ||
|
|
a81f797266 | ||
|
|
6ada79017a | ||
|
|
bdf34bbbbf | ||
|
|
c0d1b3d887 | ||
|
|
1b505d463c | ||
|
|
25606a80ec | ||
|
|
8b540bff62 | ||
|
|
8a2064e09d | ||
|
|
1f0fb32b9b | ||
|
|
3cbc345c00 | ||
|
|
3c66c18c77 | ||
|
|
bcc816ea55 | ||
|
|
eb2713bc23 | ||
|
|
bcd68ae98b | ||
|
|
d1a486ac1f | ||
|
|
0d36f5f091 | ||
|
|
3d5cdf50e6 | ||
|
|
1e81822c11 | ||
|
|
f8cd847588 | ||
|
|
5b9b454330 | ||
|
|
9c5b3f60d5 | ||
|
|
d83c255382 | ||
|
|
656b900256 | ||
|
|
a16b516163 | ||
|
|
6d8e66478c | ||
|
|
4428555762 | ||
|
|
463e91c3bd | ||
|
|
1e37f06ab6 | ||
|
|
3af3ba69f5 | ||
|
|
0adc07ac38 | ||
|
|
4fe4faa202 | ||
|
|
4912a872e0 | ||
|
|
ac05d82e3a | ||
|
|
6583f1114c | ||
|
|
e2673fa9e1 | ||
|
|
bc4b528ac6 | ||
|
|
986858345e | ||
|
|
912e439645 | ||
|
|
8e44b71614 | ||
|
|
11bca28ef5 | ||
|
|
4ef0c6021a | ||
|
|
4fbbd58ae9 | ||
|
|
81dea6cf90 | ||
|
|
9b57fb280b | ||
|
|
e804d0b31e | ||
|
|
76845fc7e4 | ||
|
|
decd7f7918 | ||
|
|
8c5ff4f230 | ||
|
|
0848702f65 | ||
|
|
5379374135 | ||
|
|
b7772128d7 | ||
|
|
95a1c7f92c | ||
|
|
2d69cd580a | ||
|
|
34552926d1 | ||
|
|
739f584921 | ||
|
|
04d8b6421a | ||
|
|
fdcd1f3d28 | ||
|
|
fc02dc02e8 | ||
|
|
6955d83b0c | ||
|
|
1b3d3a867b | ||
|
|
83718d7b31 | ||
|
|
e80627a3c3 | ||
|
|
e1003d4bb6 | ||
|
|
2ef4a52be0 | ||
|
|
93a63d3714 | ||
|
|
a8d9d98cf2 | ||
|
|
d70ddd1f53 | ||
|
|
283c7c5a26 | ||
|
|
fb5f50d5bd | ||
|
|
06e84b41aa | ||
|
|
e3f262322a | ||
|
|
70c2fa8ef4 | ||
|
|
9f534e18c8 | ||
|
|
55bd72aef8 | ||
|
|
c1a23faf1f | ||
|
|
3588c297f8 | ||
|
|
04d03cbc8f | ||
|
|
4e27efb6a1 | ||
|
|
59b3e5bb24 | ||
|
|
d8eee3d81a | ||
|
|
c8926682b2 | ||
|
|
9117a9d779 | ||
|
|
4ea1775f2c | ||
|
|
a8020ecebe | ||
|
|
2ace076fce | ||
|
|
45e897d475 | ||
|
|
98676f0573 | ||
|
|
c966ab9a52 | ||
|
|
ebaf11ad10 | ||
|
|
19c7ba03c6 | ||
|
|
894b5c5c6c | ||
|
|
516e93cee2 | ||
|
|
cc0ffc6e60 | ||
|
|
a97ace6e73 | ||
|
|
6d49463dad | ||
|
|
81e6e4e5f2 | ||
|
|
2adb355183 | ||
|
|
5e6c53432b | ||
|
|
873f77bc43 | ||
|
|
9bf098a93a | ||
|
|
388713a3c6 | ||
|
|
e94dd58542 | ||
|
|
d985a1c588 | ||
|
|
dbac6e8918 | ||
|
|
a481c0ee5e | ||
|
|
eef6fdaeb3 | ||
|
|
b8b1a5bba6 | ||
|
|
f06f52fce7 | ||
|
|
4a332bb77b | ||
|
|
eb1b202566 | ||
|
|
658f3a1a09 | ||
|
|
55eba480ac | ||
|
|
bbeea5b0ec | ||
|
|
ad454a94ef | ||
|
|
268215ff5f |
10
.devcontainer/Dockerfile
Normal file
10
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-18
|
||||
|
||||
RUN usermod -l zipline node \
|
||||
&& groupmod -n zipline node \
|
||||
&& usermod -d /home/zipline zipline \
|
||||
&& echo "zipline ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers.d/zipline \
|
||||
&& chmod 0440 /etc/sudoers.d/zipline \
|
||||
&& sudo apt-get update && apt-get install gnupg2 -y
|
||||
|
||||
USER zipline
|
||||
56
.devcontainer/devcontainer.json
Normal file
56
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "Zipline Codespace",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/zipline",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/common-utils:2": {
|
||||
"username": "zipline"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:1": {
|
||||
"dockerDashComposeVersion": "v2",
|
||||
"installDockerBuildx": true
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"files.autoSave": "afterDelay",
|
||||
"terminal.integrated.persistentSessionReviveProcess": "never",
|
||||
"terminal.integrated.defaultProfile.linux": "zsh",
|
||||
"terminal.integrated.profiles.linux": {
|
||||
"zsh": {
|
||||
"path": "/bin/zsh",
|
||||
"env": {
|
||||
"ZSH_THEME": "devcontainers"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensions": ["prisma.prisma", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
||||
}
|
||||
},
|
||||
"remoteUser": "zipline",
|
||||
"updateRemoteUserUID": true,
|
||||
"remoteEnv": {
|
||||
"CORE_DATABASE_URL": "postgres://postgres:postgres@localhost/zip10"
|
||||
},
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
"label": "Zipline",
|
||||
"onAutoForward": "openBrowser"
|
||||
},
|
||||
"5432": {
|
||||
"label": "Postgres"
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "sudo chown -R zipline:zipline /zipline && yarn install"
|
||||
}
|
||||
25
.devcontainer/docker-compose.yml
Normal file
25
.devcontainer/docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ./
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ../:/zipline:cached
|
||||
- uploads:/zipline/uploads
|
||||
- node_modules:/zipline/node_modules
|
||||
command: sleep infinity
|
||||
db:
|
||||
image: postgres:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DATABASE=postgres
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
uploads:
|
||||
node_modules:
|
||||
7
.eslintignore
Normal file
7
.eslintignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.yarn
|
||||
.devcontainer
|
||||
.github
|
||||
.next
|
||||
.vscode
|
||||
@@ -1,5 +1,13 @@
|
||||
{
|
||||
"extends": ["next", "next/core-web-vitals", "plugin:prettier/recommended"],
|
||||
"root": true,
|
||||
"extends": [
|
||||
"next",
|
||||
"next/core-web-vitals",
|
||||
"plugin:prettier/recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"plugins": ["unused-imports", "@typescript-eslint"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"rules": {
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"quotes": [
|
||||
@@ -28,6 +36,14 @@
|
||||
"react/style-prop-object": "warn",
|
||||
"@next/next/no-img-element": "off",
|
||||
"jsx-a11y/alt-text": "off",
|
||||
"react/display-name": "off"
|
||||
"react/display-name": "off",
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": [
|
||||
"error",
|
||||
{ "vars": "all", "varsIgnorePattern": "^_", "args": "after-used", "argsIgnorePattern": "^_" }
|
||||
],
|
||||
"@typescript-eslint/ban-ts-comment": "off"
|
||||
}
|
||||
}
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/config.yml
vendored
3
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,5 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feature Request
|
||||
url: https://github.com/diced/zipline/discussions/new?category=ideas&title=Your%20breif%20description%20here&labels=feature
|
||||
about: Ask for a new feature
|
||||
- name: Zipline Discord
|
||||
url: https://discord.gg/EAhCRfGxCF
|
||||
about: Ask for help with anything related to Zipline!
|
||||
|
||||
31
.github/workflows/milestone.yml
vendored
Normal file
31
.github/workflows/milestone.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: 'Issue/PR Milestones'
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
issues:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
checks: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
set:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
const milestone = 3
|
||||
github.rest.issues.update({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
milestone
|
||||
})
|
||||
28
.yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
vendored
Normal file
28
.yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,5 +5,7 @@ nodeLinker: node-modules
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
|
||||
spec: "@yarnpkg/plugin-interactive-tools"
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
|
||||
spec: "@yarnpkg/plugin-workspace-tools"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.2.4.cjs
|
||||
yarnPath: .yarn/releases/yarn-3.3.1.cjs
|
||||
|
||||
@@ -12,7 +12,7 @@ Create an issue on GitHub, please include the following (if one of them is not a
|
||||
|
||||
## Feature requests
|
||||
|
||||
Create an issue on GitHub, please include the following:
|
||||
Create an discussion on GitHub, please include the following:
|
||||
|
||||
- Breif explanation of the feature in the title (very breif please)
|
||||
- How it would work (detailed, but optional)
|
||||
|
||||
111
Dockerfile
111
Dockerfile
@@ -1,65 +1,76 @@
|
||||
FROM ghcr.io/diced/prisma-binaries:4.7.x as prisma
|
||||
# Use the Prisma binaries image as the first stage
|
||||
FROM ghcr.io/diced/prisma-binaries:4.10.x as prisma
|
||||
|
||||
FROM node:alpine3.16 AS deps
|
||||
RUN mkdir -p /prisma-engines
|
||||
WORKDIR /build
|
||||
# Use Alpine Linux as the second stage
|
||||
FROM node:18-alpine3.16 as base
|
||||
|
||||
COPY .yarn .yarn
|
||||
COPY package.json yarn.lock .yarnrc.yml ./
|
||||
|
||||
RUN yarn install --immutable
|
||||
|
||||
FROM node:alpine3.16 AS builder
|
||||
WORKDIR /build
|
||||
|
||||
COPY --from=prisma /prisma-engines /prisma-engines
|
||||
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
|
||||
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
|
||||
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
|
||||
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
|
||||
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
|
||||
PRISMA_CLIENT_ENGINE_TYPE=binary
|
||||
|
||||
RUN apk add --no-cache openssl openssl-dev
|
||||
|
||||
COPY --from=deps /build/node_modules ./node_modules
|
||||
COPY src ./src
|
||||
COPY prisma ./prisma
|
||||
COPY .yarn .yarn
|
||||
COPY package.json yarn.lock .yarnrc.yml esbuild.config.js next.config.js next-env.d.ts zip-env.d.ts tsconfig.json mimes.json ./
|
||||
|
||||
ENV ZIPLINE_DOCKER_BUILD 1
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN yarn build
|
||||
|
||||
FROM node:alpine3.16 AS runner
|
||||
# Set the working directory
|
||||
WORKDIR /zipline
|
||||
|
||||
# Copy the necessary files from the project
|
||||
COPY prisma ./prisma
|
||||
COPY src ./src
|
||||
COPY next.config.js ./next.config.js
|
||||
COPY tsup.config.ts ./tsup.config.ts
|
||||
COPY tsconfig.json ./tsconfig.json
|
||||
COPY mimes.json ./mimes.json
|
||||
COPY public ./public
|
||||
|
||||
FROM base as builder
|
||||
|
||||
COPY .yarn ./.yarn
|
||||
COPY package*.json ./
|
||||
COPY yarn*.lock ./
|
||||
COPY .yarnrc.yml ./
|
||||
|
||||
# Copy the prisma binaries from prisma stage
|
||||
COPY --from=prisma /prisma-engines /prisma-engines
|
||||
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
|
||||
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
|
||||
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
|
||||
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
|
||||
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
|
||||
PRISMA_CLIENT_ENGINE_TYPE=binary
|
||||
PRISMA_CLIENT_ENGINE_TYPE=binary \
|
||||
ZIPLINE_DOCKER_BUILD=true \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN apk add --no-cache openssl openssl-dev
|
||||
RUN apk add --no-cache perl procps
|
||||
# Install production dependencies then temporarily save
|
||||
RUN yarn workspaces focus --production --all
|
||||
RUN cp -RL node_modules /tmp/node_modules
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
# Install the dependencies
|
||||
RUN yarn install --immutable
|
||||
|
||||
COPY --from=builder /build/.next ./.next
|
||||
COPY --from=builder /build/node_modules ./node_modules
|
||||
# Run the build
|
||||
RUN yarn build
|
||||
|
||||
COPY --from=builder /build/next.config.js ./next.config.js
|
||||
COPY --from=builder /build/esbuild.config.js ./esbuild.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
|
||||
# Use Alpine Linux as the final image
|
||||
FROM base
|
||||
# Install the necessary packages
|
||||
RUN apk add --no-cache perl procps tini
|
||||
|
||||
CMD ["node", "--enable-source-maps", "dist/server"]
|
||||
COPY --from=builder /prisma-engines /prisma-engines
|
||||
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
|
||||
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
|
||||
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
|
||||
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
|
||||
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
|
||||
PRISMA_CLIENT_ENGINE_TYPE=binary \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Copy only the necessary files from the previous stage
|
||||
COPY --from=builder /zipline/dist ./dist
|
||||
COPY --from=builder /zipline/.next ./.next
|
||||
COPY --from=builder /zipline/package.json ./package.json
|
||||
|
||||
COPY --from=builder /zipline/node_modules ./node_modules
|
||||
COPY --from=builder /zipline/node_modules/.prisma/client ./node_modules/.prisma/client
|
||||
COPY --from=builder /zipline/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||
|
||||
# Copy Startup Script
|
||||
COPY docker-entrypoint.sh /zipline
|
||||
|
||||
# Make Startup Script Executable
|
||||
RUN chmod a+x /zipline/docker-entrypoint.sh && rm -rf /zipline/src
|
||||
# Set the entrypoint to the startup script
|
||||
ENTRYPOINT ["tini", "--", "/zipline/docker-entrypoint.sh"]
|
||||
17
README.md
17
README.md
@@ -25,7 +25,7 @@ A ShareX/file upload server that is easy to use, packed with features, and with
|
||||
- Password Protected Uploads
|
||||
- URL shortening
|
||||
- Text uploading
|
||||
- URL Formats (uuid, dates, random alphanumeric, original name, zws)
|
||||
- URL Formats (uuid, dates, random alphanumeric, original name, zws, gfycat -> [animals](https://assets.gfycat.com/animals) [adjectives](https://assets.gfycat.com/adjectives))
|
||||
- Discord embeds (OG metadata)
|
||||
- Gallery viewer, and multiple file format support
|
||||
- Code highlighting
|
||||
@@ -35,7 +35,16 @@ A ShareX/file upload server that is easy to use, packed with features, and with
|
||||
- User invites
|
||||
- File Chunking (for large files)
|
||||
- File deletion once it reaches a certain amount of views
|
||||
- Easy setup instructions on [docs](https://zipl.vercel.app/) (One command install `docker-compose up -d`)
|
||||
- Easy setup instructions on [docs](https://zipl.vercel.app/) (One command install `docker compose up -d`)
|
||||
|
||||
<details>
|
||||
<summary>View upstream documentation</summary>
|
||||
|
||||
The website below provides documentation for more up-to-date features with the upstream branch. The normal documentation is for the latest release and is not updated unless a new release is made.
|
||||
|
||||
[https://trunk.zipline.diced.tech/](https://trunk.zipline.diced.tech/)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h2>Screenshots (click)</h2></summary>
|
||||
@@ -51,13 +60,13 @@ A ShareX/file upload server that is easy to use, packed with features, and with
|
||||
|
||||
## Install & run with Docker
|
||||
|
||||
This section requires [Docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/).
|
||||
This section requires [Docker](https://docs.docker.com/get-docker/) and [docker compose](https://docs.docker.com/compose/install/).
|
||||
|
||||
```shell
|
||||
git clone https://github.com/diced/zipline
|
||||
cd zipline
|
||||
|
||||
docker-compose up -d
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### After installing
|
||||
|
||||
@@ -2,7 +2,6 @@ version: '3'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
@@ -21,7 +20,6 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- '3000:3000'
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.local
|
||||
volumes:
|
||||
|
||||
@@ -2,7 +2,7 @@ version: '3'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
restart: always
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
image: ghcr.io/diced/zipline
|
||||
ports:
|
||||
- '3000:3000'
|
||||
restart: always
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- CORE_RETURN_HTTPS=false
|
||||
- CORE_SECRET=changethis
|
||||
|
||||
5
docker-entrypoint.sh
Normal file
5
docker-entrypoint.sh
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
node --enable-source-maps dist/index.js
|
||||
@@ -1,23 +0,0 @@
|
||||
const esbuild = require('esbuild');
|
||||
const { existsSync } = require('fs');
|
||||
const { rm } = require('fs/promises');
|
||||
const { recursiveReadDir } = require('next/dist/lib/recursive-readdir');
|
||||
|
||||
(async () => {
|
||||
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',
|
||||
platform: 'node',
|
||||
entryPoints,
|
||||
format: 'cjs',
|
||||
resolveExtensions: ['.ts', '.js'],
|
||||
write: true,
|
||||
sourcemap: true,
|
||||
});
|
||||
})();
|
||||
117
package.json
117
package.json
@@ -1,99 +1,102 @@
|
||||
{
|
||||
"name": "zipline",
|
||||
"version": "3.6.4",
|
||||
"version": "3.7.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "npm-run-all build:server dev:run",
|
||||
"dev:run": "cross-env DEBUG=true REACT_EDITOR=code NODE_ENV=development RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED=false node --enable-source-maps dist/server",
|
||||
"dev:run": "cross-env DEBUG=true REACT_EDITOR=code NODE_ENV=development RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED=false node --enable-source-maps dist",
|
||||
"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:server": "tsup",
|
||||
"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": "node dist/server",
|
||||
"start": "node dist",
|
||||
"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:run-dev": "docker-compose --file docker-compose.dev.yml up",
|
||||
"docker:down-dev": "docker-compose --file docker-compose.dev.yml down",
|
||||
"scripts:read-config": "node dist/scripts/read-config",
|
||||
"scripts:import-dir": "node dist/scripts/import-dir",
|
||||
"scripts:list-users": "node dist/scripts/list-users",
|
||||
"scripts:set-user": "node dist/scripts/set-user"
|
||||
"compose:up": "docker compose up",
|
||||
"compose:down": "docker compose down",
|
||||
"compose:build-dev": "docker compose --file docker-compose.dev.yml up --build",
|
||||
"compose:up-dev": "docker compose --file docker-compose.dev.yml up",
|
||||
"compose:down-dev": "docker compose --file docker-compose.dev.yml down",
|
||||
"scripts:read-config": "node --enable-source-maps dist/scripts/read-config",
|
||||
"scripts:import-dir": "node --enable-source-maps dist/scripts/import-dir",
|
||||
"scripts:list-users": "node --enable-source-maps dist/scripts/list-users",
|
||||
"scripts:set-user": "node --enable-source-maps dist/scripts/set-user",
|
||||
"scripts:clear-zero-byte": "node --enable-source-maps dist/scripts/clear-zero-byte",
|
||||
"scripts:query-size": "node --enable-source-maps dist/scripts/query-size"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dicedtomato/mantine-data-grid": "0.0.23",
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/server": "^11.10.0",
|
||||
"@mantine/core": "^5.9.2",
|
||||
"@mantine/dropzone": "^5.9.2",
|
||||
"@mantine/form": "^5.9.2",
|
||||
"@mantine/hooks": "^5.9.2",
|
||||
"@mantine/modals": "^5.9.2",
|
||||
"@mantine/next": "^5.9.2",
|
||||
"@mantine/notifications": "^5.9.2",
|
||||
"@mantine/nprogress": "^5.9.2",
|
||||
"@mantine/prism": "^5.9.2",
|
||||
"@prisma/client": "^4.7.1",
|
||||
"@prisma/internals": "^4.7.1",
|
||||
"@prisma/migrate": "^4.7.1",
|
||||
"@sapphire/shapeshift": "^3.7.1",
|
||||
"@tanstack/react-query": "^4.19.1",
|
||||
"argon2": "^0.30.2",
|
||||
"chart.js": "^4.0.1",
|
||||
"colorette": "^2.0.19",
|
||||
"@mantine/core": "^6.0.4",
|
||||
"@mantine/dropzone": "^6.0.4",
|
||||
"@mantine/form": "^6.0.4",
|
||||
"@mantine/hooks": "^6.0.4",
|
||||
"@mantine/modals": "^6.0.4",
|
||||
"@mantine/next": "^6.0.4",
|
||||
"@mantine/notifications": "^6.0.4",
|
||||
"@mantine/prism": "^6.0.4",
|
||||
"@mantine/spotlight": "^6.0.4",
|
||||
"@prisma/client": "^4.10.1",
|
||||
"@prisma/internals": "^4.10.1",
|
||||
"@prisma/migrate": "^4.10.1",
|
||||
"@sapphire/shapeshift": "^3.8.1",
|
||||
"@tabler/icons-react": "^2.11.0",
|
||||
"@tanstack/react-query": "^4.28.0",
|
||||
"argon2": "^0.30.3",
|
||||
"cookie": "^0.5.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"dotenv": "^16.0.3",
|
||||
"dotenv-expand": "^9.0.0",
|
||||
"exiftool-vendored": "^18.6.0",
|
||||
"fastify": "^4.10.2",
|
||||
"fastify-plugin": "^4.4.0",
|
||||
"dotenv-expand": "^10.0.0",
|
||||
"exiftool-vendored": "^21.2.0",
|
||||
"fastify": "^4.15.0",
|
||||
"fastify-plugin": "^4.5.0",
|
||||
"fflate": "^0.7.4",
|
||||
"find-my-way": "^7.3.1",
|
||||
"find-my-way": "^7.6.0",
|
||||
"katex": "^0.16.4",
|
||||
"minio": "^7.0.32",
|
||||
"mantine-datatable": "^2.2.6",
|
||||
"minio": "^7.0.33",
|
||||
"ms": "canary",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"next": "^13.0.6",
|
||||
"next": "^13.2.4",
|
||||
"otplib": "^12.0.1",
|
||||
"prisma": "^4.7.1",
|
||||
"prisma": "^4.10.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"qrcode": "^1.5.1",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.0.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-feather": "^2.0.10",
|
||||
"react-markdown": "^8.0.4",
|
||||
"recoil": "^0.7.6",
|
||||
"react-markdown": "^8.0.6",
|
||||
"recharts": "^2.5.0",
|
||||
"recoil": "^0.7.7",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"sharp": "^0.31.2"
|
||||
"sharp": "^0.32.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie": "^0.5.1",
|
||||
"@types/katex": "^0.14.0",
|
||||
"@types/minio": "^7.0.15",
|
||||
"@types/katex": "^0.16.0",
|
||||
"@types/minio": "^7.0.17",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^18.11.12",
|
||||
"@types/node": "^18.15.10",
|
||||
"@types/qrcode": "^1.5.0",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/sharp": "^0.31.0",
|
||||
"@types/react": "^18.0.29",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.56.0",
|
||||
"@typescript-eslint/parser": "^5.56.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"esbuild": "^0.16.4",
|
||||
"eslint": "^8.29.0",
|
||||
"eslint-config-next": "^13.0.6",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint": "^8.36.0",
|
||||
"eslint-config-next": "^13.2.4",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^2.8.1",
|
||||
"typescript": "^4.9.4"
|
||||
"prettier": "^2.8.7",
|
||||
"tsup": "^6.7.0",
|
||||
"typescript": "^5.0.2"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/diced/zipline.git"
|
||||
},
|
||||
"packageManager": "yarn@3.2.4"
|
||||
"packageManager": "yarn@3.3.1"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "OAuth" ADD COLUMN "oauthId" TEXT NOT NULL;
|
||||
ALTER TABLE "OAuth" ADD COLUMN "oauthId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OAuth_provider_oauthId_key" ON "OAuth"("provider", "oauthId");
|
||||
|
||||
13
prisma/migrations/20230111055303_embed/migration.sql
Normal file
13
prisma/migrations/20230111055303_embed/migration.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `embedColor` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `embedSiteName` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `embedTitle` on the `User` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "embedColor",
|
||||
DROP COLUMN "embedSiteName",
|
||||
DROP COLUMN "embedTitle",
|
||||
ADD COLUMN "embed" JSONB NOT NULL DEFAULT '{}';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" RENAME COLUMN "created_at" TO "createdAt";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" RENAME COLUMN "expires_at" TO "expiresAt";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" RENAME COLUMN "file" TO "name";
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Url" RENAME COLUMN "created_at" TO "createdAt";
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Stats" RENAME COLUMN "created_at" TO "createdAt";
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Invite" RENAME COLUMN "created_at" TO "createdAt";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Invite" RENAME COLUMN "expires_at" TO "expiresAt";
|
||||
@@ -0,0 +1,19 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ImageFormat" RENAME TO "FileNameFormat";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" RENAME TO "File";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "InvisibleImage" RENAME TO "InvisibleFile";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "InvisibleFile" RENAME COLUMN "imageId" TO "fileId";
|
||||
|
||||
-- AlterForeignKey
|
||||
ALTER TABLE "InvisibleFile" RENAME CONSTRAINT "InvisibleImage_imageId_fkey" TO "InvisibleFile_fileId_fkey";
|
||||
ALTER INDEX "InvisibleImage_imageId_key" RENAME TO "InvisibleFile_fileId_key";
|
||||
|
||||
-- AlterForeignKey
|
||||
ALTER TABLE "InvisibleFile" RENAME CONSTRAINT "InvisibleImage_pkey" TO "InvisibleFile_pkey";
|
||||
ALTER TABLE "File" RENAME CONSTRAINT "Image_pkey" TO "File_pkey";
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "File" ADD COLUMN "originalName" TEXT;
|
||||
|
||||
-- RenameForeignKey
|
||||
ALTER TABLE "File" RENAME CONSTRAINT "Image_userId_fkey" TO "File_userId_fkey";
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "InvisibleImage_invis_key" RENAME TO "InvisibleFile_invis_key";
|
||||
19
prisma/migrations/20230128183334_folders/migration.sql
Normal file
19
prisma/migrations/20230128183334_folders/migration.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "File" ADD COLUMN "folderId" INTEGER;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Folder" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Folder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "File" ADD CONSTRAINT "File_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Folder" ADD COLUMN "public" BOOLEAN NOT NULL DEFAULT false;
|
||||
2
prisma/migrations/20230226051016_file_size/migration.sql
Normal file
2
prisma/migrations/20230226051016_file_size/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "File" ADD COLUMN "size" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `format` on the `File` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "File" DROP COLUMN "format";
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "FileNameFormat";
|
||||
@@ -0,0 +1,18 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProcessingStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "IncompleteFile" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"status" "ProcessingStatus" NOT NULL,
|
||||
"chunks" INTEGER NOT NULL,
|
||||
"chunksComplete" INTEGER NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"data" JSONB NOT NULL,
|
||||
|
||||
CONSTRAINT "IncompleteFile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "IncompleteFile" ADD CONSTRAINT "IncompleteFile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -8,89 +8,104 @@ generator client {
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String
|
||||
password String?
|
||||
avatar String?
|
||||
token String
|
||||
administrator Boolean @default(false)
|
||||
superAdmin Boolean @default(false)
|
||||
systemTheme String @default("system")
|
||||
embedTitle String?
|
||||
embedColor String @default("#2f3136")
|
||||
embedSiteName String? @default("{image.file} • {user.name}")
|
||||
ratelimit DateTime?
|
||||
totpSecret String?
|
||||
domains String[]
|
||||
oauth OAuth[]
|
||||
images Image[]
|
||||
urls Url[]
|
||||
Invite Invite[]
|
||||
id Int @id @default(autoincrement())
|
||||
username String
|
||||
password String?
|
||||
avatar String?
|
||||
token String
|
||||
administrator Boolean @default(false)
|
||||
superAdmin Boolean @default(false)
|
||||
systemTheme String @default("system")
|
||||
embed Json @default("{}")
|
||||
ratelimit DateTime?
|
||||
totpSecret String?
|
||||
domains String[]
|
||||
oauth OAuth[]
|
||||
files File[]
|
||||
urls Url[]
|
||||
Invite Invite[]
|
||||
Folder Folder[]
|
||||
IncompleteFile IncompleteFile[]
|
||||
}
|
||||
|
||||
enum ImageFormat {
|
||||
UUID
|
||||
DATE
|
||||
RANDOM
|
||||
NAME
|
||||
model Folder {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
public Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
|
||||
files File[]
|
||||
}
|
||||
|
||||
model Image {
|
||||
id Int @id @default(autoincrement())
|
||||
file String
|
||||
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], onDelete: SetNull)
|
||||
userId Int?
|
||||
model File {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
originalName String?
|
||||
mimetype String @default("image/png")
|
||||
createdAt DateTime @default(now())
|
||||
size Int @default(0)
|
||||
expiresAt DateTime?
|
||||
maxViews Int?
|
||||
views Int @default(0)
|
||||
favorite Boolean @default(false)
|
||||
embed Boolean @default(false)
|
||||
password String?
|
||||
invisible InvisibleFile?
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
userId Int?
|
||||
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
folderId Int?
|
||||
}
|
||||
|
||||
model InvisibleImage {
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
imageId Int @unique
|
||||
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
|
||||
model InvisibleFile {
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
|
||||
fileId Int @unique
|
||||
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Url {
|
||||
id String @id @unique
|
||||
destination String
|
||||
vanity String?
|
||||
created_at DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
maxViews Int?
|
||||
views Int @default(0)
|
||||
invisible InvisibleUrl?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
userId Int?
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
userId Int?
|
||||
}
|
||||
|
||||
model InvisibleUrl {
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
|
||||
urlId String @unique
|
||||
url Url @relation(fields: [urlId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Stats {
|
||||
id Int @id @default(autoincrement())
|
||||
created_at DateTime @default(now())
|
||||
data Json
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
data Json
|
||||
}
|
||||
|
||||
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], onDelete: Cascade)
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
used Boolean @default(false)
|
||||
|
||||
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||
createdById Int
|
||||
}
|
||||
|
||||
@@ -100,7 +115,7 @@ model OAuth {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
username String
|
||||
oauthId String
|
||||
oauthId String?
|
||||
token String
|
||||
refresh String?
|
||||
|
||||
@@ -112,3 +127,23 @@ enum OauthProviders {
|
||||
GITHUB
|
||||
GOOGLE
|
||||
}
|
||||
|
||||
model IncompleteFile {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
status ProcessingStatus
|
||||
chunks Int
|
||||
chunksComplete Int
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
|
||||
data Json
|
||||
}
|
||||
|
||||
enum ProcessingStatus {
|
||||
PENDING
|
||||
PROCESSING
|
||||
COMPLETE
|
||||
}
|
||||
|
||||
1501
public/adjectives.txt
Normal file
1501
public/adjectives.txt
Normal file
File diff suppressed because it is too large
Load Diff
1750
public/animals.txt
Normal file
1750
public/animals.txt
Normal file
File diff suppressed because it is too large
Load Diff
6
src/components/AnchorNext.tsx
Normal file
6
src/components/AnchorNext.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Anchor } from '@mantine/core';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AnchorNext({ href, ...others }) {
|
||||
return <Anchor component={Link} href={href} {...others} />;
|
||||
}
|
||||
@@ -1,15 +1,36 @@
|
||||
import { createStyles, MantineSize, Textarea } from '@mantine/core';
|
||||
import { createStyles, Textarea } from '@mantine/core';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const useStyles = createStyles((theme, { size }: { size: MantineSize }) => ({
|
||||
const useStyles = createStyles(() => ({
|
||||
input: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: theme.fn.size({ size, sizes: theme.fontSizes }) - 2,
|
||||
height: '80vh',
|
||||
},
|
||||
}));
|
||||
|
||||
export default function CodeInput({ ...props }) {
|
||||
const { classes } = useStyles({ size: 'md' }, { name: 'CodeInput' });
|
||||
const { classes } = useStyles(null, { name: 'CodeInput' });
|
||||
|
||||
return <Textarea classNames={{ input: classes.input }} autoComplete='nope' {...props} />;
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Tab') {
|
||||
if (document.activeElement?.tagName !== 'TEXTAREA') return;
|
||||
|
||||
e.preventDefault();
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
const start = target.selectionStart;
|
||||
const end = target.selectionEnd;
|
||||
target.value = `${target.value.substring(0, start)} ${target.value.substring(end)}`;
|
||||
target.selectionStart = target.selectionEnd = start + 2;
|
||||
target.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <Textarea classNames={{ input: classes.input }} {...props} />;
|
||||
}
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
import { Button, Card, Group, LoadingOverlay, Modal, Stack, Text, Title, Tooltip } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
|
||||
import { relativeTime } from 'lib/utils/client';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
CrossIcon,
|
||||
DeleteIcon,
|
||||
ExternalLinkIcon,
|
||||
EyeIcon,
|
||||
FileIcon,
|
||||
HashIcon,
|
||||
ImageIcon,
|
||||
StarIcon,
|
||||
} from './icons';
|
||||
import Link from './Link';
|
||||
import MutedText from './MutedText';
|
||||
import Markdown from './render/Markdown';
|
||||
import Type from './Type';
|
||||
|
||||
export function FileMeta({ Icon, title, subtitle, ...other }) {
|
||||
return other.tooltip ? (
|
||||
<Group>
|
||||
<Icon size={24} />
|
||||
<Tooltip label={other.tooltip}>
|
||||
<Stack spacing={1}>
|
||||
<Text>{title}</Text>
|
||||
<MutedText size='md'>{subtitle}</MutedText>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
) : (
|
||||
<Group>
|
||||
<Icon size={24} />
|
||||
<Stack spacing={1}>
|
||||
<Text>{title}</Text>
|
||||
<MutedText size='md'>{subtitle}</MutedText>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function File({ image, disableMediaPreview, exifEnabled }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [overrideRender, setOverrideRender] = useState(false);
|
||||
const deleteFile = useFileDelete();
|
||||
const favoriteFile = useFileFavorite();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const loading = deleteFile.isLoading || favoriteFile.isLoading;
|
||||
|
||||
const handleDelete = async () => {
|
||||
deleteFile.mutate(image.id, {
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'File Deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <DeleteIcon />,
|
||||
});
|
||||
},
|
||||
|
||||
onError: (res: any) => {
|
||||
showNotification({
|
||||
title: 'Failed to delete file',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${image.url}`);
|
||||
setOpen(false);
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
favoriteFile.mutate(
|
||||
{ id: image.id, favorite: !image.favorite },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'Image is now ' + (!image.favorite ? 'favorited' : 'unfavorited'),
|
||||
message: '',
|
||||
icon: <StarIcon />,
|
||||
});
|
||||
},
|
||||
|
||||
onError: (res: any) => {
|
||||
showNotification({
|
||||
title: 'Failed to favorite file',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>{image.file}</Title>} size='xl'>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Stack>
|
||||
<Type
|
||||
file={image}
|
||||
src={`/r/${image.file}`}
|
||||
alt={image.file}
|
||||
popup
|
||||
sx={{ minHeight: 200 }}
|
||||
style={{ minHeight: 200 }}
|
||||
disableMediaPreview={false}
|
||||
overrideRender={overrideRender}
|
||||
setOverrideRender={setOverrideRender}
|
||||
/>
|
||||
<Stack>
|
||||
<FileMeta Icon={FileIcon} title='Name' subtitle={image.file} />
|
||||
<FileMeta Icon={ImageIcon} title='Type' subtitle={image.mimetype} />
|
||||
<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='md'>
|
||||
{exifEnabled && (
|
||||
<Link href={`/dashboard/metadata/${image.id}`} target='_blank' rel='noopener noreferrer'>
|
||||
<Button leftIcon={<ExternalLinkIcon />}>View Metadata</Button>
|
||||
</Link>
|
||||
)}
|
||||
<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={`/r/${image.file}`}
|
||||
alt={image.file}
|
||||
onClick={() => setOpen(true)}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
358
src/components/File/FileModal.tsx
Normal file
358
src/components/File/FileModal.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useClipboard, useMediaQuery } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconAlarm,
|
||||
IconCalendarPlus,
|
||||
IconClipboardCopy,
|
||||
IconDeviceSdCard,
|
||||
IconExternalLink,
|
||||
IconEye,
|
||||
IconEyeglass,
|
||||
IconFile,
|
||||
IconFileDownload,
|
||||
IconFolderCancel,
|
||||
IconFolderMinus,
|
||||
IconFolderPlus,
|
||||
IconHash,
|
||||
IconInfoCircle,
|
||||
IconPhoto,
|
||||
IconPhotoCancel,
|
||||
IconPhotoMinus,
|
||||
IconPhotoStar,
|
||||
} from '@tabler/icons-react';
|
||||
import useFetch, { ApiError } from 'hooks/useFetch';
|
||||
import { useFileDelete, useFileFavorite, UserFilesResponse } from 'lib/queries/files';
|
||||
import { useFolders } from 'lib/queries/folders';
|
||||
import { bytesToHuman } from 'lib/utils/bytes';
|
||||
import { relativeTime } from 'lib/utils/client';
|
||||
import { useState } from 'react';
|
||||
import { FileMeta } from '.';
|
||||
import Type from '../Type';
|
||||
|
||||
export default function FileModal({
|
||||
open,
|
||||
setOpen,
|
||||
file,
|
||||
loading,
|
||||
refresh,
|
||||
reducedActions = false,
|
||||
exifEnabled,
|
||||
compress,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
file: UserFilesResponse;
|
||||
loading: boolean;
|
||||
refresh: () => void;
|
||||
reducedActions?: boolean;
|
||||
exifEnabled?: boolean;
|
||||
compress: boolean;
|
||||
}) {
|
||||
const deleteFile = useFileDelete();
|
||||
const favoriteFile = useFileFavorite();
|
||||
const folders = useFolders();
|
||||
|
||||
const [overrideRender, setOverrideRender] = useState(false);
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const handleDelete = async () => {
|
||||
deleteFile.mutate(file.id, {
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'File Deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <IconPhotoMinus size='1rem' />,
|
||||
});
|
||||
},
|
||||
|
||||
onError: (res: ApiError) => {
|
||||
showNotification({
|
||||
title: 'Failed to delete file',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <IconPhotoCancel size='1rem' />,
|
||||
});
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${file.url}`);
|
||||
setOpen(false);
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: 'Zipline is unable to copy to clipboard due to security reasons.',
|
||||
color: 'red',
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
favoriteFile.mutate(
|
||||
{ id: file.id, favorite: !file.favorite },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'The file is now ' + (!file.favorite ? 'favorited' : 'unfavorited'),
|
||||
message: '',
|
||||
icon: <IconPhotoStar size='1rem' />,
|
||||
});
|
||||
},
|
||||
|
||||
onError: (res: { error: string }) => {
|
||||
showNotification({
|
||||
title: 'Failed to favorite file',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <IconPhotoCancel size='1rem' />,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const inFolder = file.folderId;
|
||||
|
||||
const removeFromFolder = async () => {
|
||||
const res = await useFetch('/api/user/folders/' + file.folderId, 'DELETE', {
|
||||
file: Number(file.id),
|
||||
});
|
||||
|
||||
refresh();
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Removed from folder',
|
||||
message: res.name,
|
||||
color: 'green',
|
||||
icon: <IconFolderMinus size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to remove from folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <IconFolderCancel size='1rem' />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addToFolder = async (t) => {
|
||||
const res = await useFetch('/api/user/folders/' + t, 'POST', {
|
||||
file: Number(file.id),
|
||||
});
|
||||
|
||||
refresh();
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Added to folder',
|
||||
message: res.name,
|
||||
color: 'green',
|
||||
icon: <IconFolderPlus size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to add to folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <IconFolderCancel size='1rem' />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const createFolder = (t) => {
|
||||
useFetch('/api/user/folders', 'POST', {
|
||||
name: t,
|
||||
add: [Number(file.id)],
|
||||
}).then((res) => {
|
||||
refresh();
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Created & added to folder',
|
||||
message: res.name,
|
||||
color: 'green',
|
||||
icon: <IconFolderPlus size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to create folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <IconFolderCancel size='1rem' />,
|
||||
});
|
||||
}
|
||||
});
|
||||
return { value: t, label: t };
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Title>{file.name}</Title>}
|
||||
size='auto'
|
||||
fullScreen={useMediaQuery('(max-width: 600px)')}
|
||||
>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Stack>
|
||||
<Type
|
||||
file={file}
|
||||
src={`/r/${encodeURI(file.name)}?compress=${compress}`}
|
||||
alt={file.name}
|
||||
popup
|
||||
sx={{ minHeight: 200 }}
|
||||
style={{ minHeight: 200 }}
|
||||
disableMediaPreview={false}
|
||||
overrideRender={overrideRender}
|
||||
setOverrideRender={setOverrideRender}
|
||||
/>
|
||||
<SimpleGrid
|
||||
my='md'
|
||||
cols={3}
|
||||
breakpoints={[
|
||||
{ maxWidth: 600, cols: 1 },
|
||||
{ maxWidth: 900, cols: 2 },
|
||||
{ maxWidth: 1200, cols: 3 },
|
||||
]}
|
||||
>
|
||||
<FileMeta Icon={IconFile} title='Name' subtitle={file.name} />
|
||||
<FileMeta Icon={IconPhoto} title='Type' subtitle={file.mimetype} />
|
||||
<FileMeta Icon={IconDeviceSdCard} title='Size' subtitle={bytesToHuman(file.size || 0)} />
|
||||
<FileMeta Icon={IconEye} title='Views' subtitle={file?.views?.toLocaleString()} />
|
||||
{file.maxViews && (
|
||||
<FileMeta
|
||||
Icon={IconEyeglass}
|
||||
title='Max views'
|
||||
subtitle={file?.maxViews?.toLocaleString()}
|
||||
tooltip={`This file will be deleted after being viewed ${file?.maxViews?.toLocaleString()} times.`}
|
||||
/>
|
||||
)}
|
||||
<FileMeta
|
||||
Icon={IconCalendarPlus}
|
||||
title='Uploaded'
|
||||
subtitle={relativeTime(new Date(file.createdAt))}
|
||||
tooltip={new Date(file?.createdAt).toLocaleString()}
|
||||
/>
|
||||
{file.expiresAt && !reducedActions && (
|
||||
<FileMeta
|
||||
Icon={IconAlarm}
|
||||
title='Expires'
|
||||
subtitle={relativeTime(new Date(file.expiresAt))}
|
||||
tooltip={new Date(file.expiresAt).toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
<FileMeta Icon={IconHash} title='ID' subtitle={file.id} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Group position='apart' my='md'>
|
||||
<Group position='left'>
|
||||
{exifEnabled && !reducedActions && (
|
||||
<Tooltip label='View Metadata'>
|
||||
<ActionIcon
|
||||
color='blue'
|
||||
variant='filled'
|
||||
onClick={() => window.open(`/dashboard/metadata/${file.id}`, '_blank')}
|
||||
>
|
||||
<IconInfoCircle size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{reducedActions ? null : inFolder && !folders.isLoading ? (
|
||||
<Tooltip
|
||||
label={`Remove from folder "${folders.data.find((f) => f.id === file.folderId)?.name ?? ''}"`}
|
||||
>
|
||||
<ActionIcon color='red' variant='filled' onClick={removeFromFolder} loading={folders.isLoading}>
|
||||
<IconFolderMinus size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label='Add to folder'>
|
||||
<Select
|
||||
onChange={addToFolder}
|
||||
placeholder='Add to folder'
|
||||
data={[
|
||||
...(folders.data ? folders.data : []).map((folder) => ({
|
||||
value: String(folder.id),
|
||||
label: `${folder.id}: ${folder.name}`,
|
||||
})),
|
||||
]}
|
||||
searchable
|
||||
creatable
|
||||
getCreateLabel={(query) => `Create folder "${query}"`}
|
||||
onCreate={createFolder}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
{reducedActions ? null : (
|
||||
<>
|
||||
<Tooltip label='Delete file'>
|
||||
<ActionIcon color='red' variant='filled' onClick={handleDelete}>
|
||||
<IconPhotoMinus size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={file.favorite ? 'Unfavorite' : 'Favorite'}>
|
||||
<ActionIcon
|
||||
color={file.favorite ? 'yellow' : 'gray'}
|
||||
variant='filled'
|
||||
onClick={handleFavorite}
|
||||
>
|
||||
<IconPhotoStar size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip label='Open in new tab'>
|
||||
<ActionIcon color='blue' variant='filled' onClick={() => window.open(file.url, '_blank')}>
|
||||
<IconExternalLink size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Copy URL'>
|
||||
<ActionIcon color='blue' variant='filled' onClick={handleCopy}>
|
||||
<IconClipboardCopy size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Download'>
|
||||
<ActionIcon
|
||||
color='blue'
|
||||
variant='filled'
|
||||
onClick={() => window.open(`/r/${encodeURI(file.name)}?download=true`, '_blank')}
|
||||
>
|
||||
<IconFileDownload size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
91
src/components/File/index.tsx
Normal file
91
src/components/File/index.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Card, Group, LoadingOverlay, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
|
||||
import { useFolders } from 'lib/queries/folders';
|
||||
import { useState } from 'react';
|
||||
import MutedText from '../MutedText';
|
||||
import Type from '../Type';
|
||||
import FileModal from './FileModal';
|
||||
|
||||
export function FileMeta({ Icon, title, subtitle, ...other }) {
|
||||
return other.tooltip ? (
|
||||
<Group>
|
||||
<Icon size={24} />
|
||||
<Tooltip label={other.tooltip}>
|
||||
<Stack spacing={1}>
|
||||
<Text>{title}</Text>
|
||||
<MutedText size='md'>{subtitle}</MutedText>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
) : (
|
||||
<Group>
|
||||
<Icon size={24} />
|
||||
<Stack spacing={1}>
|
||||
<Text>{title}</Text>
|
||||
<MutedText size='md'>{subtitle}</MutedText>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function File({
|
||||
image,
|
||||
disableMediaPreview,
|
||||
exifEnabled,
|
||||
refreshImages,
|
||||
reducedActions = false,
|
||||
onDash,
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const deleteFile = useFileDelete();
|
||||
const favoriteFile = useFileFavorite();
|
||||
const loading = deleteFile.isLoading || favoriteFile.isLoading;
|
||||
|
||||
const folders = useFolders();
|
||||
|
||||
const refresh = () => {
|
||||
refreshImages();
|
||||
folders.refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileModal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
file={image}
|
||||
loading={loading}
|
||||
refresh={refresh}
|
||||
reducedActions={reducedActions}
|
||||
exifEnabled={exifEnabled}
|
||||
compress={onDash}
|
||||
/>
|
||||
|
||||
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md' onClick={() => setOpen(true)}>
|
||||
<Card.Section>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Type
|
||||
file={image}
|
||||
sx={{
|
||||
minHeight: 200,
|
||||
maxHeight: 320,
|
||||
fontSize: 70,
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
style={{
|
||||
minHeight: 200,
|
||||
maxHeight: 320,
|
||||
fontSize: 70,
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
src={`/r/${encodeURI(image.name)}?compress=${onDash}`}
|
||||
alt={image.name}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,171 +7,124 @@ import {
|
||||
Group,
|
||||
Header,
|
||||
Image,
|
||||
Input,
|
||||
MediaQuery,
|
||||
Menu,
|
||||
Navbar,
|
||||
NavLink,
|
||||
Paper,
|
||||
Popover,
|
||||
rem,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { useClipboard, useMediaQuery } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconBackspace,
|
||||
IconBrandDiscordFilled,
|
||||
IconBrandGithubFilled,
|
||||
IconBrandGoogle,
|
||||
IconBrush,
|
||||
IconClipboardCopy,
|
||||
IconExternalLink,
|
||||
IconFiles,
|
||||
IconFileText,
|
||||
IconFileUpload,
|
||||
IconFolders,
|
||||
IconGraph,
|
||||
IconHome,
|
||||
IconLink,
|
||||
IconLogout,
|
||||
IconReload,
|
||||
IconSettings,
|
||||
IconTag,
|
||||
IconUpload,
|
||||
IconUser,
|
||||
IconUserCog,
|
||||
IconUsers,
|
||||
} from '@tabler/icons-react';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useVersion } from 'lib/queries/version';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { capitalize } from 'lib/utils/client';
|
||||
import { UserExtended } from 'middleware/withZipline';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import {
|
||||
ActivityIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
CrossIcon,
|
||||
DeleteIcon,
|
||||
DiscordIcon,
|
||||
ExternalLinkIcon,
|
||||
FileIcon,
|
||||
GitHubIcon,
|
||||
GoogleIcon,
|
||||
HomeIcon,
|
||||
LinkIcon,
|
||||
LogoutIcon,
|
||||
PencilIcon,
|
||||
SettingsIcon,
|
||||
TagIcon,
|
||||
TypeIcon,
|
||||
UploadIcon,
|
||||
UserIcon,
|
||||
} from './icons';
|
||||
import { friendlyThemeName, themes } from './Theming';
|
||||
|
||||
function MenuItemLink(props) {
|
||||
return (
|
||||
<Link href={props.href} passHref legacyBehavior>
|
||||
<MenuItem {...props} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem(props) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
sx={(theme) => ({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: 5,
|
||||
borderRadius: theme.radius.sm,
|
||||
color: props.color
|
||||
? theme.fn.themeColor(props.color, theme.colorScheme === 'dark' ? 5 : 7)
|
||||
: theme.colorScheme === 'dark'
|
||||
? 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,
|
||||
|
||||
'& *': {
|
||||
display: 'block',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{props.icon}
|
||||
</Box>
|
||||
<Text size='sm'>{props.children}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
export type NavbarItems = {
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
link?: string;
|
||||
children?: NavbarItems[];
|
||||
if?: (user: any, props: any) => boolean;
|
||||
if?: (user: UserExtended, props: unknown) => boolean;
|
||||
};
|
||||
|
||||
const items: NavbarItems[] = [
|
||||
{
|
||||
icon: <HomeIcon size={18} />,
|
||||
icon: <IconHome size={18} />,
|
||||
text: 'Home',
|
||||
link: '/dashboard',
|
||||
},
|
||||
{
|
||||
icon: <FileIcon size={18} />,
|
||||
icon: <IconFiles size={18} />,
|
||||
text: 'Files',
|
||||
link: '/dashboard/files',
|
||||
},
|
||||
{
|
||||
icon: <ActivityIcon size={18} />,
|
||||
icon: <IconFolders size={18} />,
|
||||
text: 'Folders',
|
||||
link: '/dashboard/folders',
|
||||
},
|
||||
{
|
||||
icon: <IconGraph size={18} />,
|
||||
text: 'Stats',
|
||||
link: '/dashboard/stats',
|
||||
},
|
||||
{
|
||||
icon: <LinkIcon size={18} />,
|
||||
icon: <IconLink size={18} />,
|
||||
text: 'URLs',
|
||||
link: '/dashboard/urls',
|
||||
},
|
||||
{
|
||||
icon: <UploadIcon size={18} />,
|
||||
icon: <IconUpload size={18} />,
|
||||
text: 'Upload',
|
||||
children: [
|
||||
{
|
||||
icon: <UploadIcon size={18} />,
|
||||
icon: <IconFileUpload size={18} />,
|
||||
text: 'File',
|
||||
link: '/dashboard/upload/file',
|
||||
},
|
||||
{
|
||||
icon: <TypeIcon size={18} />,
|
||||
icon: <IconFileText size={18} />,
|
||||
text: 'Text',
|
||||
link: '/dashboard/upload/text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <UserIcon size={18} />,
|
||||
icon: <IconUser size={18} />,
|
||||
text: 'Administration',
|
||||
if: (user, _) => user.administrator as boolean,
|
||||
children: [
|
||||
{
|
||||
icon: <UserIcon size={18} />,
|
||||
icon: <IconUsers size={18} />,
|
||||
text: 'Users',
|
||||
link: '/dashboard/users',
|
||||
if: () => true,
|
||||
},
|
||||
{
|
||||
icon: <TagIcon size={18} />,
|
||||
icon: <IconTag size={18} />,
|
||||
text: 'Invites',
|
||||
link: '/dashboard/invites',
|
||||
if: (_, props) => props.invites,
|
||||
if: (_, props: { invites: boolean }) => props.invites,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -183,9 +136,9 @@ export default function Layout({ children, props }) {
|
||||
const { title, oauth_providers: unparsed } = props;
|
||||
const oauth_providers = JSON.parse(unparsed);
|
||||
const icons = {
|
||||
GitHub: GitHubIcon,
|
||||
Discord: DiscordIcon,
|
||||
Google: GoogleIcon,
|
||||
GitHub: IconBrandGithubFilled,
|
||||
Discord: IconBrandDiscordFilled,
|
||||
Google: IconBrandGoogle,
|
||||
};
|
||||
|
||||
for (const provider of oauth_providers) {
|
||||
@@ -198,7 +151,6 @@ export default function Layout({ children, props }) {
|
||||
const [systemTheme, setSystemTheme] = useState(user.systemTheme ?? 'system');
|
||||
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();
|
||||
@@ -219,7 +171,7 @@ export default function Layout({ children, props }) {
|
||||
title: `Theme changed to ${friendlyThemeName[value]}`,
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <PencilIcon />,
|
||||
icon: <IconBrush size='1rem' />,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -240,7 +192,7 @@ export default function Layout({ children, props }) {
|
||||
title: 'Token Reset Failed',
|
||||
message: a.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
icon: <IconReload size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
@@ -248,7 +200,7 @@ export default function Layout({ children, props }) {
|
||||
message:
|
||||
'Your token has been reset. You will need to update any uploaders to use this new token.',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
icon: <IconReload size='1rem' />,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -268,13 +220,29 @@ export default function Layout({ children, props }) {
|
||||
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 />,
|
||||
});
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: (
|
||||
<Text size='sm'>
|
||||
Zipline is unable to copy to clipboard due to security reasons. However, you can still copy
|
||||
the token manually.
|
||||
<br />
|
||||
<Group position='left' spacing='sm'>
|
||||
<Text>Your token is:</Text>
|
||||
<Input size='sm' onFocus={(e) => e.target.select()} type='text' value={token} />
|
||||
</Group>
|
||||
</Text>
|
||||
),
|
||||
color: 'red',
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
title: 'Token Copied',
|
||||
message: 'Your token has been copied to your clipboard.',
|
||||
color: 'green',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
|
||||
modals.closeAll();
|
||||
},
|
||||
@@ -300,42 +268,42 @@ export default function Layout({ children, props }) {
|
||||
{children
|
||||
.filter((x) => (x.if ? x.if(user, props) : true))
|
||||
.map(({ icon, text, link }) => (
|
||||
<Link href={link} key={text} passHref legacyBehavior>
|
||||
<NavLink
|
||||
component='a'
|
||||
label={text}
|
||||
icon={icon}
|
||||
active={router.pathname === link}
|
||||
variant='light'
|
||||
/>
|
||||
</Link>
|
||||
<NavLink
|
||||
key={text}
|
||||
label={text}
|
||||
icon={icon}
|
||||
active={router.pathname === link}
|
||||
variant='light'
|
||||
component={Link}
|
||||
href={link}
|
||||
/>
|
||||
))}
|
||||
</NavLink>
|
||||
) : (
|
||||
<Link href={link} key={text} passHref legacyBehavior>
|
||||
<NavLink
|
||||
component='a'
|
||||
label={text}
|
||||
icon={icon}
|
||||
active={router.pathname === link}
|
||||
variant='light'
|
||||
/>
|
||||
</Link>
|
||||
<NavLink
|
||||
key={text}
|
||||
label={text}
|
||||
icon={icon}
|
||||
active={router.pathname === link}
|
||||
variant='light'
|
||||
component={Link}
|
||||
href={link}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Navbar.Section>
|
||||
<Navbar.Section>
|
||||
{external_links.length
|
||||
? external_links.map(({ label, link }, i) => (
|
||||
<Link href={link} passHref key={i} legacyBehavior>
|
||||
<NavLink
|
||||
label={label}
|
||||
component='a'
|
||||
target='_blank'
|
||||
variant='light'
|
||||
icon={<ExternalLinkIcon />}
|
||||
/>
|
||||
</Link>
|
||||
? external_links.map(({ label, link }, i: number) => (
|
||||
<NavLink
|
||||
key={i}
|
||||
label={label}
|
||||
target='_blank'
|
||||
variant='light'
|
||||
icon={<IconExternalLink size={18} />}
|
||||
component={Link}
|
||||
href={link}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</Navbar.Section>
|
||||
@@ -343,9 +311,11 @@ export default function Layout({ children, props }) {
|
||||
<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'
|
||||
version.data.update
|
||||
? `There is a new ${version.data.updateToType} version: ${
|
||||
version.data.versions[version.data.updateToType]
|
||||
}`
|
||||
: `You are running the latest ${version.data.isUpstream ? 'upstream' : 'stable'} version`
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
@@ -353,9 +323,9 @@ export default function Layout({ children, props }) {
|
||||
radius='md'
|
||||
size='lg'
|
||||
variant='dot'
|
||||
color={version.data.local !== version.data.upstream ? 'red' : 'primary'}
|
||||
color={version.data.update ? 'red' : 'primary'}
|
||||
>
|
||||
{version.data.local}
|
||||
{version.data.versions.current}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Navbar.Section>
|
||||
@@ -375,99 +345,118 @@ export default function Layout({ children, props }) {
|
||||
</MediaQuery>
|
||||
<Title ml='sm'>{title}</Title>
|
||||
<Box sx={{ marginLeft: 'auto', marginRight: 0 }}>
|
||||
<Popover position='bottom-end' opened={open} onClose={() => setOpen(false)}>
|
||||
<Popover.Target>
|
||||
<Menu
|
||||
styles={{
|
||||
item: {
|
||||
'@media (max-width: 768px)': {
|
||||
padding: '1rem',
|
||||
width: '80vw',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
leftIcon={avatar ? <Image src={avatar} height={32} radius='md' /> : <SettingsIcon />}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
sx={(t) => ({
|
||||
backgroundColor: 'inherit',
|
||||
'&:hover': {
|
||||
backgroundColor: t.other.hover,
|
||||
},
|
||||
color: t.colorScheme === 'dark' ? 'white' : 'black',
|
||||
})}
|
||||
leftIcon={
|
||||
avatar ? <Image src={avatar} height={32} radius='md' /> : <IconUserCog size='1rem' />
|
||||
}
|
||||
variant='subtle'
|
||||
color='gray'
|
||||
compact
|
||||
size='xl'
|
||||
p='sm'
|
||||
>
|
||||
{user.username}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>
|
||||
{user.username} ({user.id}){' '}
|
||||
{user.administrator && user.username !== 'administrator' ? '(Administrator)' : ''}
|
||||
</Menu.Label>
|
||||
<Menu.Item component={Link} icon={<IconFiles size='1rem' />} href='/dashboard/files'>
|
||||
Files
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
icon={<IconFileUpload size='1rem' />}
|
||||
href='/dashboard/upload/file'
|
||||
>
|
||||
Upload File
|
||||
</Menu.Item>
|
||||
<Menu.Item component={Link} icon={<IconLink size='1rem' />} href='/dashboard/urls'>
|
||||
Shorten URL
|
||||
</Menu.Item>
|
||||
|
||||
<Popover.Dropdown p={4} mr='md' sx={{ minWidth: '200px' }}>
|
||||
<Stack spacing={2}>
|
||||
<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();
|
||||
}}
|
||||
>
|
||||
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) =>
|
||||
<Menu.Label>Settings</Menu.Label>
|
||||
<Menu.Item component={Link} icon={<IconSettings size='1rem' />} href='/dashboard/manage'>
|
||||
Manage Account
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
icon={<IconClipboardCopy size='1rem' />}
|
||||
onClick={() => {
|
||||
openCopyToken();
|
||||
}}
|
||||
>
|
||||
Copy Token
|
||||
</Menu.Item>
|
||||
<Menu.Item icon={<IconLogout size='1rem' />} component={Link} href='/auth/logout'>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Label>Danger</Menu.Label>
|
||||
<Menu.Item
|
||||
icon={<IconBackspace size='1rem' />}
|
||||
onClick={() => {
|
||||
openResetToken();
|
||||
}}
|
||||
color='red'
|
||||
>
|
||||
Reset Token
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<>
|
||||
{oauth_providers.filter((x) =>
|
||||
user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase())
|
||||
).length ? (
|
||||
<Menu.Label>Connected Accounts</Menu.Label>
|
||||
) : null}
|
||||
{oauth_providers
|
||||
.filter((x) =>
|
||||
user.oauth
|
||||
?.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],
|
||||
}))}
|
||||
value={systemTheme}
|
||||
onChange={handleUpdateTheme}
|
||||
/>
|
||||
</MenuItem>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)
|
||||
.map(({ name, Icon }, i) => (
|
||||
<>
|
||||
<Menu.Item
|
||||
closeMenuOnClick={false}
|
||||
key={i}
|
||||
icon={<Icon size={18} colorScheme={theme.colorScheme} />}
|
||||
>
|
||||
Logged in with {capitalize(name)}
|
||||
</Menu.Item>
|
||||
</>
|
||||
))}
|
||||
{oauth_providers.filter((x) =>
|
||||
user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase())
|
||||
).length ? (
|
||||
<Menu.Divider />
|
||||
) : null}
|
||||
</>
|
||||
<Menu.Item closeMenuOnClick={false} icon={<IconBrush size='1rem' />}>
|
||||
<Select
|
||||
size={useMediaQuery('(max-width: 768px)') ? 'md' : 'xs'}
|
||||
data={Object.keys(themes).map((t) => ({
|
||||
value: t,
|
||||
label: friendlyThemeName[t],
|
||||
}))}
|
||||
value={systemTheme}
|
||||
onChange={handleUpdateTheme}
|
||||
/>
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
</div>
|
||||
</Header>
|
||||
@@ -476,9 +465,15 @@ export default function Layout({ children, props }) {
|
||||
<Paper
|
||||
withBorder
|
||||
p='md'
|
||||
mr='md'
|
||||
mb='md'
|
||||
shadow='xs'
|
||||
sx={(t) => ({
|
||||
borderColor: t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.dark[0],
|
||||
sx={(theme) => ({
|
||||
'&[data-with-border]': {
|
||||
border: `${rem(1)} solid ${
|
||||
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[0]
|
||||
}`,
|
||||
},
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { NextLink } from '@mantine/next';
|
||||
|
||||
export default function Link(props) {
|
||||
return <NextLink legacyBehavior {...props} />;
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
// https://mantine.dev/core/password-input/
|
||||
|
||||
import { Box, PasswordInput, Popover, Progress, Text } from '@mantine/core';
|
||||
import { IconCheck, IconCross } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { CheckIcon, CrossIcon } from './icons';
|
||||
|
||||
function PasswordRequirement({ meets, label }: { meets: boolean; label: string }) {
|
||||
return (
|
||||
<Text color={meets ? 'teal' : 'red'} sx={{ display: 'flex', alignItems: 'center' }} mt='sm' size='sm'>
|
||||
{meets ? <CheckIcon /> : <CrossIcon />} <Box ml='md'>{label}</Box>
|
||||
{meets ? <IconCheck size='1rem' /> : <IconCross size='1rem' />} <Box ml='md'>{label}</Box>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Card, createStyles, Group, Text } from '@mantine/core';
|
||||
import { ArrowDownRight, ArrowUpRight } from 'react-feather';
|
||||
import { IconArrowDownRight, IconArrowUpRight } from '@tabler/icons-react';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
root: {
|
||||
padding: theme.spacing.xl * 1.5,
|
||||
padding: `calc(${theme.spacing.xl} * 1.5)`,
|
||||
},
|
||||
|
||||
value: {
|
||||
@@ -57,7 +57,7 @@ export default function StatCard({ stat }: StatsGridProps) {
|
||||
<>
|
||||
<Text color={stat.diff >= 0 ? 'teal' : 'red'} size='sm' weight={500} className={classes.diff}>
|
||||
<span>{stat.diff === Infinity ? '∞' : stat.diff}%</span>
|
||||
{stat.diff >= 0 ? <ArrowUpRight size={16} /> : <ArrowDownRight size={16} />}
|
||||
{stat.diff >= 0 ? <IconArrowUpRight size={16} /> : <IconArrowDownRight size={16} />}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -15,10 +15,15 @@ import qogir_dark from 'lib/themes/qogir_dark';
|
||||
import { createEmotionCache, MantineProvider, MantineThemeOverride } from '@mantine/core';
|
||||
import { useColorScheme } from '@mantine/hooks';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import { NotificationsProvider } from '@mantine/notifications';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import { SpotlightProvider } from '@mantine/spotlight';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { createSpotlightActions } from 'lib/spotlight';
|
||||
import { useRouter } from 'next/router';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
|
||||
export const themes = {
|
||||
system: (colorScheme: 'dark' | 'light') => (colorScheme === 'dark' ? dark_blue : light_blue),
|
||||
dark_blue,
|
||||
@@ -52,6 +57,7 @@ const cache = createEmotionCache({ key: 'zipline' });
|
||||
export default function ZiplineTheming({ Component, pageProps, ...props }) {
|
||||
const user = useRecoilValue(userSelector);
|
||||
const colorScheme = useColorScheme();
|
||||
const router = useRouter();
|
||||
|
||||
let theme: MantineThemeOverride;
|
||||
|
||||
@@ -78,7 +84,7 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
|
||||
components: {
|
||||
AppShell: {
|
||||
styles: (t) => ({
|
||||
root: {
|
||||
main: {
|
||||
backgroundColor: t.other.AppShell_backgroundColor,
|
||||
},
|
||||
}),
|
||||
@@ -92,10 +98,15 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
|
||||
},
|
||||
Modal: {
|
||||
defaultProps: {
|
||||
closeButtonProps: { size: 'lg' },
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
overlayColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
|
||||
exitTransitionDuration: 100,
|
||||
transitionProps: {
|
||||
exitDuration: 100,
|
||||
},
|
||||
overlayProps: {
|
||||
blur: 6,
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
|
||||
},
|
||||
},
|
||||
},
|
||||
Popover: {
|
||||
@@ -106,8 +117,8 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
|
||||
},
|
||||
LoadingOverlay: {
|
||||
defaultProps: {
|
||||
overlayBlur: 3,
|
||||
overlayColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
|
||||
overlayOpacity: 0.3,
|
||||
},
|
||||
},
|
||||
Loader: {
|
||||
@@ -133,9 +144,14 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
|
||||
}}
|
||||
>
|
||||
<ModalsProvider>
|
||||
<NotificationsProvider>
|
||||
<SpotlightProvider
|
||||
searchIcon={<IconSearch size='1rem' />}
|
||||
shortcut={['mod + k', '/']}
|
||||
actions={createSpotlightActions(router)}
|
||||
>
|
||||
<Notifications position='top-center' style={{ marginTop: -10 }} />
|
||||
{props.children ? props.children : <Component {...pageProps} />}
|
||||
</NotificationsProvider>
|
||||
</SpotlightProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import { Alert, Box, Button, Card, Center, Container, Group, Image, Text } from '@mantine/core';
|
||||
import { Prism } from '@mantine/prism';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
LoadingOverlay,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconFile,
|
||||
IconFileAlert,
|
||||
IconFileText,
|
||||
IconFileUnknown,
|
||||
IconHeadphones,
|
||||
IconPhotoCancel,
|
||||
IconPlayerPlay,
|
||||
} from '@tabler/icons-react';
|
||||
import exts from 'lib/exts';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AudioIcon, FileIcon, ImageIcon, PlayIcon } from './icons';
|
||||
import KaTeX from './render/KaTeX';
|
||||
import Markdown from './render/Markdown';
|
||||
import PrismCode from './render/PrismCode';
|
||||
@@ -16,9 +35,18 @@ function PlaceholderContent({ text, Icon }) {
|
||||
}
|
||||
|
||||
function Placeholder({ text, Icon, ...props }) {
|
||||
if (props.onClick)
|
||||
return (
|
||||
<UnstyledButton sx={{ height: 200 }} {...props}>
|
||||
<Center sx={{ height: 200 }}>
|
||||
<PlaceholderContent text={text} Icon={Icon} />
|
||||
</Center>
|
||||
</UnstyledButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ height: 200 }} {...props}>
|
||||
<Center sx={{ height: 200 }}>
|
||||
<Box sx={{ height: 320 }} {...props}>
|
||||
<Center sx={{ height: 320 }}>
|
||||
<PlaceholderContent text={text} Icon={Icon} />
|
||||
</Center>
|
||||
</Box>
|
||||
@@ -26,27 +54,33 @@ function Placeholder({ text, Icon, ...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 type =
|
||||
(file.type ?? file.mimetype) === ''
|
||||
? file.name.split('.').pop()
|
||||
: (file.type ?? file.mimetype).split('/')[0];
|
||||
|
||||
const media = /^(video|audio|image|text)/.test(type);
|
||||
|
||||
const [text, setText] = useState('');
|
||||
const shouldRenderMarkdown = name.endsWith('.md');
|
||||
const shouldRenderTex = name.endsWith('.tex');
|
||||
const shouldRenderMarkdown = file.name.endsWith('.md');
|
||||
const shouldRenderTex = file.name.endsWith('.tex');
|
||||
const shouldRenderCode: boolean = Object.keys(exts).includes(file.name.split('.').pop());
|
||||
|
||||
if (type === 'text' && popup) {
|
||||
const [loading, setLoading] = useState(type === 'text' && popup);
|
||||
|
||||
if ((type === 'text' || shouldRenderMarkdown || shouldRenderTex || shouldRenderCode) && popup) {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetch('/r/' + name);
|
||||
const res = await fetch('/r/' + file.name);
|
||||
const text = await res.text();
|
||||
|
||||
setText(text);
|
||||
setLoading(false);
|
||||
})();
|
||||
}, []);
|
||||
}
|
||||
|
||||
const renderRenderAlert = () => {
|
||||
const renderAlert = () => {
|
||||
return (
|
||||
<Alert color='blue' variant='outline' sx={{ width: '100%' }}>
|
||||
You are{props.overrideRender ? ' not ' : ' '}viewing a rendered version of the file
|
||||
@@ -62,36 +96,61 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
|
||||
);
|
||||
};
|
||||
|
||||
if ((shouldRenderMarkdown || shouldRenderTex) && !props.overrideRender && popup)
|
||||
if ((shouldRenderMarkdown || shouldRenderTex || shouldRenderCode) && !props.overrideRender && popup)
|
||||
return (
|
||||
<>
|
||||
{renderRenderAlert()}
|
||||
{renderAlert()}
|
||||
<Card p='md' my='sm'>
|
||||
{shouldRenderMarkdown && <Markdown code={text} />}
|
||||
{shouldRenderTex && <KaTeX code={text} />}
|
||||
{shouldRenderCode && !(shouldRenderTex || shouldRenderMarkdown) && (
|
||||
<PrismCode code={text} ext={type} />
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
|
||||
if (media && disableMediaPreview) {
|
||||
return <Placeholder Icon={FileIcon} text={`Click to view file (${name})`} {...props} />;
|
||||
return <Placeholder Icon={IconFile} text={`Click to view file (${file.name})`} {...props} />;
|
||||
}
|
||||
|
||||
if (file.password) {
|
||||
return (
|
||||
<Placeholder
|
||||
Icon={IconFileAlert}
|
||||
text={`This file is password protected. Click to view file (${file.name})`}
|
||||
onClick={() => window.open(file.url)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return popup ? (
|
||||
media ? (
|
||||
{
|
||||
video: <video width='100%' autoPlay controls {...props} />,
|
||||
video: <video width='100%' autoPlay muted controls {...props} />,
|
||||
image: (
|
||||
<Image
|
||||
placeholder={<PlaceholderContent Icon={FileIcon} text={'Image failed to load...'} />}
|
||||
styles={{
|
||||
imageWrapper: {
|
||||
position: 'inherit',
|
||||
},
|
||||
}}
|
||||
placeholder={<PlaceholderContent Icon={IconPhotoCancel} text={'Image failed to load...'} />}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
audio: <audio autoPlay controls {...props} style={{ width: '100%' }} />,
|
||||
audio: <audio autoPlay muted controls {...props} style={{ width: '100%' }} />,
|
||||
text: (
|
||||
<>
|
||||
{(shouldRenderMarkdown || shouldRenderTex) && renderRenderAlert()}
|
||||
<PrismCode code={text} ext={name.split('.').pop()} {...props} />
|
||||
{loading ? (
|
||||
<LoadingOverlay visible={loading} />
|
||||
) : (
|
||||
<>
|
||||
{(shouldRenderMarkdown || shouldRenderTex) && renderAlert()}
|
||||
<PrismCode code={text} ext={file.name.split('.').pop()} {...props} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}[type]
|
||||
@@ -100,17 +159,19 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
|
||||
)
|
||||
) : media ? (
|
||||
{
|
||||
video: <Placeholder Icon={PlayIcon} text={`Click to view video (${name})`} {...props} />,
|
||||
video: <Placeholder Icon={IconPlayerPlay} text={`Click to view video (${file.name})`} {...props} />,
|
||||
image: (
|
||||
<Image
|
||||
placeholder={<PlaceholderContent Icon={ImageIcon} text={'Image failed to load...'} />}
|
||||
placeholder={<PlaceholderContent Icon={IconPhotoCancel} text={'Image failed to load...'} />}
|
||||
height={320}
|
||||
fit='contain'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
audio: <Placeholder Icon={AudioIcon} text={`Click to view audio (${name})`} {...props} />,
|
||||
text: <Placeholder Icon={FileIcon} text={`Click to view text file (${name})`} {...props} />,
|
||||
audio: <Placeholder Icon={IconHeadphones} text={`Click to view audio (${file.name})`} {...props} />,
|
||||
text: <Placeholder Icon={IconFileText} text={`Click to view text file (${file.name})`} {...props} />,
|
||||
}[type]
|
||||
) : (
|
||||
<Placeholder Icon={FileIcon} text={`Click to view file (${name})`} {...props} />
|
||||
<Placeholder Icon={IconFileUnknown} text={`Click to view file (${file.name})`} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { Group, Text, useMantineTheme } from '@mantine/core';
|
||||
import { Box, Group, SimpleGrid, Text } from '@mantine/core';
|
||||
import { Dropzone as MantineDropzone } from '@mantine/dropzone';
|
||||
import { ImageIcon } from 'components/icons';
|
||||
import { IconPhoto } from '@tabler/icons-react';
|
||||
|
||||
export default function Dropzone({ loading, onDrop, children }) {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
<MantineDropzone onDrop={onDrop}>
|
||||
<Group position='center' spacing='xl' style={{ minHeight: 440 }}>
|
||||
<ImageIcon size={80} />
|
||||
<SimpleGrid
|
||||
cols={2}
|
||||
breakpoints={[
|
||||
{ maxWidth: 'md', cols: 1 },
|
||||
{ maxWidth: 'xs', cols: 1 },
|
||||
]}
|
||||
>
|
||||
<MantineDropzone loading={loading} onDrop={onDrop} styles={{ inner: { pointerEvents: 'none' } }}>
|
||||
<Group position='center' spacing='xl' style={{ minHeight: 440 }}>
|
||||
<IconPhoto size={80} />
|
||||
|
||||
<Text size='xl' inline>
|
||||
Drag files here or click to select files
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<div style={{ pointerEvents: 'all' }}>{children}</div>
|
||||
</MantineDropzone>
|
||||
<Text size='xl' inline>
|
||||
Drag files here or click to select files
|
||||
</Text>
|
||||
</Group>
|
||||
</MantineDropzone>
|
||||
<Box>{children}</Box>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Badge, Group, HoverCard, Table, useMantineTheme } from '@mantine/core';
|
||||
import { ActionIcon, Box, Card, Group, HoverCard, Table, useMantineTheme } from '@mantine/core';
|
||||
import { IconX } from '@tabler/icons-react';
|
||||
import Type from 'components/Type';
|
||||
|
||||
export function FilePreview({ file }: { file: File }) {
|
||||
@@ -16,15 +17,34 @@ export function FilePreview({ file }: { file: File }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function FileDropzone({ file }: { file: File }) {
|
||||
export default function FileDropzone({ file, onRemove }: { file: File; onRemove: () => void }) {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
<HoverCard shadow='md'>
|
||||
<HoverCard.Target>
|
||||
<Badge size='lg'>{file.name}</Badge>
|
||||
<Card shadow='sm' radius='sm' p='sm'>
|
||||
<Group position='center' spacing='xl'>
|
||||
{file.name}
|
||||
</Group>
|
||||
</Card>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
color: theme.colorScheme === 'dark' ? 'white' : 'white',
|
||||
}}
|
||||
m='xs'
|
||||
>
|
||||
<ActionIcon onClick={onRemove} size='sm' color='red' variant='filled'>
|
||||
<IconX />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
|
||||
<Group grow>
|
||||
<FilePreview file={file} />
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Activity } from 'react-feather';
|
||||
|
||||
export default function ActivityIcon({ ...props }) {
|
||||
return <Activity size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Disc } from 'react-feather';
|
||||
|
||||
export default function AudioIcon({ ...props }) {
|
||||
return <Disc size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Calendar } from 'react-feather';
|
||||
|
||||
export default function CalendarIcon({ ...props }) {
|
||||
return <Calendar size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Check } from 'react-feather';
|
||||
|
||||
export default function CheckIcon({ ...props }) {
|
||||
return <Check size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Clock } from 'react-feather';
|
||||
|
||||
export default function ClockIcon({ ...props }) {
|
||||
return <Clock size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Copy } from 'react-feather';
|
||||
|
||||
export default function CopyIcon({ ...props }) {
|
||||
return <Copy size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { X } from 'react-feather';
|
||||
|
||||
export default function CrossIcon({ ...props }) {
|
||||
return <X size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Database } from 'react-feather';
|
||||
|
||||
export default function DatabaseIcon({ ...props }) {
|
||||
return <Database size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Delete } from 'react-feather';
|
||||
|
||||
export default function DeleteIcon({ ...props }) {
|
||||
return <Delete size={15} {...props} />;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Download } from 'react-feather';
|
||||
|
||||
export default function DownloadIcon({ ...props }) {
|
||||
return <Download size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { LogIn } from 'react-feather';
|
||||
|
||||
export default function EnterIcon({ ...props }) {
|
||||
return <LogIn size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ExternalLink } from 'react-feather';
|
||||
|
||||
export default function ExternalLinkIcon({ ...props }) {
|
||||
return <ExternalLink size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Eye } from 'react-feather';
|
||||
|
||||
export default function EyeIcon({ ...props }) {
|
||||
return <Eye size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { File } from 'react-feather';
|
||||
|
||||
export default function FileIcon({ ...props }) {
|
||||
return <File size={15} {...props} />;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { GitHub } from 'react-feather';
|
||||
import Image from 'next/image';
|
||||
|
||||
// https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg
|
||||
export default function GitHubIcon({ colorScheme, ...props }) {
|
||||
return (
|
||||
<svg width={24} height={24} viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg' {...props}>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z'
|
||||
transform='scale(64)'
|
||||
fill={colorScheme === 'dark' ? '#FFFFFF' : '#1B1F23'}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Hash } from 'react-feather';
|
||||
|
||||
export default function HashIcon({ ...props }) {
|
||||
return <Hash size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Home } from 'react-feather';
|
||||
|
||||
export default function HomeIcon({ ...props }) {
|
||||
return <Home size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Image as FeatherImage } from 'react-feather';
|
||||
|
||||
export default function ImageIcon({ ...props }) {
|
||||
return <FeatherImage size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Key } from 'react-feather';
|
||||
|
||||
export default function KeyIcon({ ...props }) {
|
||||
return <Key size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Link } from 'react-feather';
|
||||
|
||||
export default function LinkIcon({ ...props }) {
|
||||
return <Link size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { LogOut } from 'react-feather';
|
||||
|
||||
export default function LogoutIcon({ ...props }) {
|
||||
return <LogOut size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Edit2 } from 'react-feather';
|
||||
|
||||
export default function PencilIcon({ ...props }) {
|
||||
return <Edit2 size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Play } from 'react-feather';
|
||||
|
||||
export default function PlayIcon({ ...props }) {
|
||||
return <Play size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Plus } from 'react-feather';
|
||||
|
||||
export default function PlusIcon({ ...props }) {
|
||||
return <Plus size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { RefreshCw } from 'react-feather';
|
||||
|
||||
export default function RefreshIcon({ ...props }) {
|
||||
return <RefreshCw size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Settings } from 'react-feather';
|
||||
|
||||
export default function SettingsIcon({ ...props }) {
|
||||
return <Settings size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Star } from 'react-feather';
|
||||
|
||||
export default function StarIcon({ ...props }) {
|
||||
return <Star size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Tag } from 'react-feather';
|
||||
|
||||
export default function TagIcon({ ...props }) {
|
||||
return <Tag size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Trash2 } from 'react-feather';
|
||||
|
||||
export default function TrashIcon({ ...props }) {
|
||||
return <Trash2 size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Type } from 'react-feather';
|
||||
|
||||
export default function TypeIcon({ ...props }) {
|
||||
return <Type size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Upload } from 'react-feather';
|
||||
|
||||
export default function UploadIcon({ ...props }) {
|
||||
return <Upload size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { User } from 'react-feather';
|
||||
|
||||
export default function UserIcon({ ...props }) {
|
||||
return <User size={15} {...props} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Video } from 'react-feather';
|
||||
|
||||
export default function VideoIcon({ ...props }) {
|
||||
return <Video size={15} {...props} />;
|
||||
}
|
||||
@@ -1,75 +1,4 @@
|
||||
import ActivityIcon from './ActivityIcon';
|
||||
import CheckIcon from './CheckIcon';
|
||||
import CopyIcon from './CopyIcon';
|
||||
import CrossIcon from './CrossIcon';
|
||||
import DeleteIcon from './DeleteIcon';
|
||||
import FileIcon from './FileIcon';
|
||||
import HomeIcon from './HomeIcon';
|
||||
import LinkIcon from './LinkIcon';
|
||||
import LogoutIcon from './LogoutIcon';
|
||||
import PencilIcon from './PencilIcon';
|
||||
import SettingsIcon from './SettingsIcon';
|
||||
import TypeIcon from './TypeIcon';
|
||||
import UploadIcon from './UploadIcon';
|
||||
import UserIcon from './UserIcon';
|
||||
import EnterIcon from './EnterIcon';
|
||||
import PlusIcon from './PlusIcon';
|
||||
import ImageIcon from './ImageIcon';
|
||||
import StarIcon from './StarIcon';
|
||||
import AudioIcon from './AudioIcon';
|
||||
import VideoIcon from './VideoIcon';
|
||||
import PlayIcon from './PlayIcon';
|
||||
import CalendarIcon from './CalendarIcon';
|
||||
import HashIcon from './HashIcon';
|
||||
import TagIcon from './TagIcon';
|
||||
import ClockIcon from './ClockIcon';
|
||||
import ExternalLinkIcon from './ExternalLinkIcon';
|
||||
import ShareXIcon from './ShareXIcon';
|
||||
import DownloadIcon from './DownloadIcon';
|
||||
import FlameshotIcon from './FlameshotIcon';
|
||||
import GitHubIcon from './GitHubIcon';
|
||||
import DiscordIcon from './DiscordIcon';
|
||||
import GoogleIcon from './GoogleIcon';
|
||||
import EyeIcon from './EyeIcon';
|
||||
import RefreshIcon from './RefreshIcon';
|
||||
import KeyIcon from './KeyIcon';
|
||||
import DatabaseIcon from './DatabaseIcon';
|
||||
|
||||
export {
|
||||
ActivityIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
CrossIcon,
|
||||
DeleteIcon,
|
||||
FileIcon,
|
||||
HomeIcon,
|
||||
LinkIcon,
|
||||
LogoutIcon,
|
||||
PencilIcon,
|
||||
SettingsIcon,
|
||||
TypeIcon,
|
||||
UploadIcon,
|
||||
UserIcon,
|
||||
EnterIcon,
|
||||
PlusIcon,
|
||||
ImageIcon,
|
||||
StarIcon,
|
||||
AudioIcon,
|
||||
VideoIcon,
|
||||
PlayIcon,
|
||||
CalendarIcon,
|
||||
HashIcon,
|
||||
TagIcon,
|
||||
ClockIcon,
|
||||
ExternalLinkIcon,
|
||||
ShareXIcon,
|
||||
DownloadIcon,
|
||||
FlameshotIcon,
|
||||
GitHubIcon,
|
||||
DiscordIcon,
|
||||
GoogleIcon,
|
||||
EyeIcon,
|
||||
RefreshIcon,
|
||||
KeyIcon,
|
||||
DatabaseIcon,
|
||||
};
|
||||
export { ShareXIcon, FlameshotIcon };
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Card as MantineCard, Center, Group, SimpleGrid, Skeleton, Title } from '@mantine/core';
|
||||
import { randomId } from '@mantine/hooks';
|
||||
import { IconCloudUpload } from '@tabler/icons-react';
|
||||
import File from 'components/File';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { useRecent } from 'lib/queries/files';
|
||||
import { UploadCloud } from 'react-feather';
|
||||
|
||||
export default function RecentFiles({ disableMediaPreview, exifEnabled }) {
|
||||
export default function RecentFiles({ disableMediaPreview, exifEnabled, compress }) {
|
||||
const recent = useRecent('media');
|
||||
|
||||
return (
|
||||
@@ -24,6 +24,8 @@ export default function RecentFiles({ disableMediaPreview, exifEnabled }) {
|
||||
image={image}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
exifEnabled={exifEnabled}
|
||||
refreshImages={recent.refetch}
|
||||
onDash={compress}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
@@ -31,7 +33,7 @@ export default function RecentFiles({ disableMediaPreview, exifEnabled }) {
|
||||
<Center>
|
||||
<Group>
|
||||
<div>
|
||||
<UploadCloud size={48} />
|
||||
<IconCloudUpload size={48} />
|
||||
</div>
|
||||
<div>
|
||||
<Title>Nothing here</Title>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { SimpleGrid } from '@mantine/core';
|
||||
import { IconDatabase, IconEye, IconFile, IconUsers } from '@tabler/icons-react';
|
||||
import StatCard from 'components/StatCard';
|
||||
import { useStats } from 'lib/queries/stats';
|
||||
import { percentChange } from 'lib/utils/client';
|
||||
import { EyeIcon, DatabaseIcon, UserIcon, FileIcon } from 'components/icons';
|
||||
|
||||
export function StatCards() {
|
||||
const stats = useStats();
|
||||
@@ -20,10 +20,10 @@ export function StatCards() {
|
||||
>
|
||||
<StatCard
|
||||
stat={{
|
||||
title: 'UPLOADED FILES',
|
||||
title: 'FILES',
|
||||
value: stats.isSuccess ? latest.data.count.toLocaleString() : '...',
|
||||
desc: 'files have been uploaded',
|
||||
icon: <FileIcon />,
|
||||
icon: <IconFile />,
|
||||
diff:
|
||||
stats.isSuccess && before?.data ? percentChange(before.data.count, latest.data.count) : undefined,
|
||||
}}
|
||||
@@ -33,8 +33,8 @@ export function StatCards() {
|
||||
stat={{
|
||||
title: 'STORAGE',
|
||||
value: stats.isSuccess ? latest.data.size : '...',
|
||||
desc: 'of storage used',
|
||||
icon: <DatabaseIcon />,
|
||||
desc: 'used',
|
||||
icon: <IconDatabase />,
|
||||
diff:
|
||||
stats.isSuccess && before?.data
|
||||
? percentChange(before.data.size_num, latest.data.size_num)
|
||||
@@ -46,8 +46,8 @@ export function StatCards() {
|
||||
stat={{
|
||||
title: 'VIEWS',
|
||||
value: stats.isSuccess ? latest.data.views_count.toLocaleString() : '...',
|
||||
desc: 'total page views',
|
||||
icon: <EyeIcon />,
|
||||
desc: 'total file views',
|
||||
icon: <IconEye />,
|
||||
diff:
|
||||
stats.isSuccess && before?.data
|
||||
? percentChange(before.data.views_count, latest.data.views_count)
|
||||
@@ -59,8 +59,8 @@ export function StatCards() {
|
||||
stat={{
|
||||
title: 'USERS',
|
||||
value: stats.isSuccess ? latest.data.count_users.toLocaleString() : '...',
|
||||
desc: 'total registered users',
|
||||
icon: <UserIcon />,
|
||||
desc: 'users',
|
||||
icon: <IconUsers />,
|
||||
}}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -1,155 +1,255 @@
|
||||
import { DataGrid, dateFilterFn, stringFilterFn } from '@dicedtomato/mantine-data-grid';
|
||||
import { Title, useMantineTheme, Box } from '@mantine/core';
|
||||
import { ActionIcon, Box, Group, Title, Tooltip } 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 {
|
||||
IconClipboardCopy,
|
||||
IconExternalLink,
|
||||
IconGridDots,
|
||||
IconPhotoCancel,
|
||||
IconPhotoMinus,
|
||||
IconPhotoUp,
|
||||
} from '@tabler/icons-react';
|
||||
import FileModal from 'components/File/FileModal';
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { useFiles, useRecent } from 'lib/queries/files';
|
||||
import { usePaginatedFiles, useRecent } from 'lib/queries/files';
|
||||
import { useStats } from 'lib/queries/stats';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { bytesToHuman } from 'lib/utils/bytes';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import RecentFiles from './RecentFiles';
|
||||
import { StatCards } from './StatCards';
|
||||
|
||||
export default function Dashboard({ disableMediaPreview, exifEnabled }) {
|
||||
export default function Dashboard({ disableMediaPreview, exifEnabled, compress }) {
|
||||
const user = useRecoilValue(userSelector);
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const images = useFiles();
|
||||
const recent = useRecent('media');
|
||||
const stats = useStats();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const updateImages = () => {
|
||||
images.refetch();
|
||||
// pagination
|
||||
const [, setNumPages] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [numFiles, setNumFiles] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { count } = await useFetch('/api/user/paged?count=true');
|
||||
setNumPages(count);
|
||||
|
||||
const { count: filesCount } = await useFetch('/api/user/files?count=true');
|
||||
setNumFiles(filesCount);
|
||||
})();
|
||||
}, [page]);
|
||||
|
||||
const files = usePaginatedFiles(page, 'none');
|
||||
|
||||
// sorting
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
|
||||
columnAccessor: 'date',
|
||||
direction: 'asc',
|
||||
});
|
||||
const [records, setRecords] = useState(files.data);
|
||||
|
||||
useEffect(() => {
|
||||
setRecords(files.data);
|
||||
}, [files.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!records || records.length === 0) return;
|
||||
|
||||
const sortedRecords = [...records].sort((a, b) => {
|
||||
if (sortStatus.direction === 'asc') {
|
||||
return a[sortStatus.columnAccessor] > b[sortStatus.columnAccessor] ? 1 : -1;
|
||||
}
|
||||
|
||||
return a[sortStatus.columnAccessor] < b[sortStatus.columnAccessor] ? 1 : -1;
|
||||
});
|
||||
|
||||
setRecords(sortedRecords);
|
||||
}, [sortStatus]);
|
||||
|
||||
// file modal on click
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
|
||||
const updateFiles = () => {
|
||||
files.refetch();
|
||||
recent.refetch();
|
||||
stats.refetch();
|
||||
};
|
||||
|
||||
const deleteImage = async ({ original }) => {
|
||||
const deleteFile = async (file) => {
|
||||
const res = await useFetch('/api/user/files', 'DELETE', {
|
||||
id: original.id,
|
||||
id: file.id,
|
||||
});
|
||||
if (!res.error) {
|
||||
updateImages();
|
||||
updateFiles();
|
||||
showNotification({
|
||||
title: 'File Deleted',
|
||||
message: `${original.name}`,
|
||||
message: `${file.name}`,
|
||||
color: 'green',
|
||||
icon: <DeleteIcon />,
|
||||
icon: <IconPhotoMinus size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to Delete File',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
icon: <IconPhotoCancel size='1rem' />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const copyImage = async ({ original }) => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${original.url}`);
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: (
|
||||
<a
|
||||
href={`${window.location.protocol}//${window.location.host}${original.url}`}
|
||||
>{`${window.location.protocol}//${window.location.host}${original.url}`}</a>
|
||||
),
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
const copyFile = async (file) => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${file.url}`);
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: 'Zipline is unable to copy to clipboard due to security reasons.',
|
||||
color: 'red',
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: (
|
||||
<a
|
||||
href={`${window.location.protocol}//${window.location.host}${file.url}`}
|
||||
>{`${window.location.protocol}//${window.location.host}${file.url}`}</a>
|
||||
),
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
};
|
||||
|
||||
const viewImage = async ({ original }) => {
|
||||
window.open(`${window.location.protocol}//${window.location.host}${original.url}`);
|
||||
const viewFile = async (file) => {
|
||||
window.open(`${window.location.protocol}//${window.location.host}${file.url}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{selectedFile && (
|
||||
<FileModal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
file={selectedFile}
|
||||
loading={files.isLoading}
|
||||
refresh={() => files.refetch()}
|
||||
reducedActions={false}
|
||||
exifEnabled={exifEnabled}
|
||||
compress={compress}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Title>Welcome back, {user?.username}</Title>
|
||||
<MutedText size='md'>
|
||||
You have <b>{images.isSuccess ? images.data.length : '...'}</b> files
|
||||
You have <b>{numFiles === 0 ? '...' : numFiles}</b> files
|
||||
</MutedText>
|
||||
|
||||
<StatCards />
|
||||
|
||||
<RecentFiles disableMediaPreview={disableMediaPreview} exifEnabled={exifEnabled} />
|
||||
<RecentFiles disableMediaPreview={disableMediaPreview} exifEnabled={exifEnabled} compress={compress} />
|
||||
|
||||
<Box my='sm'>
|
||||
<Title>Files</Title>
|
||||
<MutedText size='md'>
|
||||
View your gallery <Link href='/dashboard/files'>here</Link>.
|
||||
</MutedText>
|
||||
<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={<></>}
|
||||
<Group mb='md'>
|
||||
<Title>Files</Title>
|
||||
<Tooltip label='View Gallery'>
|
||||
<ActionIcon variant='filled' color='primary' component={Link} href='/dashboard/files'>
|
||||
<IconGridDots size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<DataTable
|
||||
withBorder
|
||||
borderRadius='md'
|
||||
highlightOnHover
|
||||
verticalSpacing='sm'
|
||||
columns={[
|
||||
{ accessor: 'name', sortable: true },
|
||||
{ accessor: 'mimetype', sortable: true },
|
||||
{ accessor: 'size', sortable: true, render: (file) => bytesToHuman(file.size) },
|
||||
{
|
||||
accessorKey: 'file',
|
||||
header: 'Name',
|
||||
filterFn: stringFilterFn,
|
||||
accessor: 'createdAt',
|
||||
sortable: true,
|
||||
render: (file) => new Date(file.createdAt).toLocaleString(),
|
||||
},
|
||||
{
|
||||
accessorKey: 'mimetype',
|
||||
header: 'Type',
|
||||
filterFn: stringFilterFn,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Date',
|
||||
filterFn: dateFilterFn,
|
||||
accessor: 'actions',
|
||||
textAlignment: 'right',
|
||||
render: (file) => (
|
||||
<Group spacing={4} position='right' noWrap>
|
||||
<Tooltip label='More details'>
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
setSelectedFile(file);
|
||||
setOpen(true);
|
||||
}}
|
||||
color='blue'
|
||||
>
|
||||
<IconPhotoUp size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Open file in new tab'>
|
||||
<ActionIcon onClick={() => viewFile(file)} color='blue'>
|
||||
<IconExternalLink size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<ActionIcon onClick={() => copyFile(file)} color='green'>
|
||||
<IconClipboardCopy size='1rem' />
|
||||
</ActionIcon>
|
||||
<ActionIcon onClick={() => deleteFile(file)} color='red'>
|
||||
<IconPhotoMinus size='1rem' />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
records={records ?? []}
|
||||
fetching={files.isLoading}
|
||||
loaderBackgroundBlur={5}
|
||||
loaderVariant='dots'
|
||||
minHeight={620}
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
recordsPerPage={16}
|
||||
totalRecords={numFiles}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
rowContextMenu={{
|
||||
shadow: 'xl',
|
||||
borderRadius: 'md',
|
||||
items: (file) => [
|
||||
{
|
||||
key: 'view',
|
||||
icon: <IconExternalLink size='1rem' />,
|
||||
title: `View ${file.name}`,
|
||||
onClick: () => viewFile(file),
|
||||
},
|
||||
{
|
||||
key: 'copy',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
title: `Copy ${file.name}`,
|
||||
onClick: () => copyFile(file),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
icon: <IconPhotoMinus size='1rem' />,
|
||||
title: `Delete ${file.name}`,
|
||||
onClick: () => deleteFile(file),
|
||||
},
|
||||
],
|
||||
}}
|
||||
onCellClick={({ column, record: file }) => {
|
||||
if (column.accessor === 'actions') return;
|
||||
|
||||
setSelectedFile(file);
|
||||
setOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Button, Center, Checkbox, Group, Pagination, SimpleGrid, Skeleton, Title } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { IconFile } from '@tabler/icons-react';
|
||||
import File from 'components/File';
|
||||
import { FileIcon } from 'components/icons';
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { usePaginatedFiles } from 'lib/queries/files';
|
||||
@@ -9,11 +10,13 @@ import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
export default function FilePagation({ disableMediaPreview, exifEnabled, queryPage }) {
|
||||
export default function FilePagation({ disableMediaPreview, exifEnabled, queryPage, compress }) {
|
||||
const [checked, setChecked] = useRecoilState(showNonMediaSelector);
|
||||
const [numPages, setNumPages] = useState(Number(queryPage)); // just set it to the queryPage, since the req may have not loaded yet
|
||||
const [page, setPage] = useState(Number(queryPage));
|
||||
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,7 +44,7 @@ export default function FilePagation({ disableMediaPreview, exifEnabled, queryPa
|
||||
<Center sx={{ flexDirection: 'column' }}>
|
||||
<Group>
|
||||
<div>
|
||||
<FileIcon size={48} />
|
||||
<IconFile size={48} />
|
||||
</div>
|
||||
<div>
|
||||
<Title>Nothing here</Title>
|
||||
@@ -67,7 +70,13 @@ export default function FilePagation({ disableMediaPreview, exifEnabled, queryPa
|
||||
? pages.data.length
|
||||
? pages.data.map((image) => (
|
||||
<div key={image.id}>
|
||||
<File image={image} disableMediaPreview={disableMediaPreview} exifEnabled={exifEnabled} />
|
||||
<File
|
||||
image={image}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
exifEnabled={exifEnabled}
|
||||
refreshImages={pages.refetch}
|
||||
onDash={compress}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
: null
|
||||
@@ -87,13 +96,15 @@ export default function FilePagation({ disableMediaPreview, exifEnabled, queryPa
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<div></div>
|
||||
<Pagination total={numPages} page={page} onChange={setPage} />
|
||||
<Checkbox
|
||||
label='Show non-media files'
|
||||
checked={checked}
|
||||
onChange={(event) => setChecked(event.currentTarget.checked)}
|
||||
/>
|
||||
{!isMobile && <div></div>}
|
||||
<Pagination total={numPages} value={page} onChange={setPage} withEdges />
|
||||
{!isMobile && (
|
||||
<Checkbox
|
||||
label='Show non-media files'
|
||||
checked={checked}
|
||||
onChange={(event) => setChecked(event.currentTarget.checked)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
118
src/components/pages/Files/PendingFilesModal.tsx
Normal file
118
src/components/pages/Files/PendingFilesModal.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Button, Modal, Title, Tooltip } from '@mantine/core';
|
||||
import { IconTrash } from '@tabler/icons-react';
|
||||
import AnchorNext from 'components/AnchorNext';
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { DataTable } from 'mantine-datatable';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type PendingFiles = {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
status: string;
|
||||
chunks: number;
|
||||
chunksComplete: number;
|
||||
userId: number;
|
||||
data: {
|
||||
file: {
|
||||
filename: string;
|
||||
mimetype: string;
|
||||
lastchunk: boolean;
|
||||
identifier: string;
|
||||
totalBytes: number;
|
||||
};
|
||||
code?: number;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function PendingFilesModal({ open, onClose }) {
|
||||
const [incFiles, setIncFiles] = useState<PendingFiles[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [selectedFiles, setSelectedFiles] = useState<PendingFiles[]>([]);
|
||||
|
||||
async function updateIncFiles() {
|
||||
setLoading(true);
|
||||
|
||||
const files = await useFetch('/api/user/pending');
|
||||
setIncFiles(files);
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function deleteIncFiles() {
|
||||
await useFetch('/api/user/pending', 'DELETE', {
|
||||
id: selectedFiles.map((file) => file.id),
|
||||
});
|
||||
updateIncFiles();
|
||||
setSelectedFiles([]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateIncFiles();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (open) updateIncFiles();
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Modal title={<Title>Pending Files</Title>} size='auto' opened={open} onClose={onClose}>
|
||||
<MutedText size='xs'>Refreshing every 5 seconds...</MutedText>
|
||||
<DataTable
|
||||
withBorder
|
||||
borderRadius='md'
|
||||
highlightOnHover
|
||||
verticalSpacing='sm'
|
||||
minHeight={200}
|
||||
records={incFiles ?? []}
|
||||
columns={[
|
||||
{ accessor: 'id', title: 'ID' },
|
||||
{ accessor: 'createdAt', render: (file) => new Date(file.createdAt).toLocaleString() },
|
||||
{ accessor: 'status', render: (file) => file.status.toLowerCase() },
|
||||
{
|
||||
accessor: 'progress',
|
||||
title: 'Progress',
|
||||
render: (file) => `${file.chunksComplete}/${file.chunks} chunks`,
|
||||
},
|
||||
{
|
||||
accessor: 'message',
|
||||
render: (file) =>
|
||||
file.data.code === 200 ? (
|
||||
<AnchorNext href={file.data.message} target='_blank'>
|
||||
view file
|
||||
</AnchorNext>
|
||||
) : (
|
||||
file.data.message
|
||||
),
|
||||
},
|
||||
]}
|
||||
fetching={loading}
|
||||
loaderBackgroundBlur={5}
|
||||
loaderVariant='dots'
|
||||
onSelectedRecordsChange={setSelectedFiles}
|
||||
selectedRecords={selectedFiles}
|
||||
/>
|
||||
|
||||
{selectedFiles.length ? (
|
||||
<Tooltip label='Clearing pending files will still leave the final file on the server.'>
|
||||
<Button
|
||||
variant='filled'
|
||||
my='md'
|
||||
color='red'
|
||||
onClick={deleteIncFiles}
|
||||
leftIcon={<IconTrash size='1rem' />}
|
||||
fullWidth
|
||||
>
|
||||
Clear {selectedFiles.length} pending file{selectedFiles.length > 1 ? 's' : ''}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Title } from '@mantine/core';
|
||||
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Title, Tooltip } from '@mantine/core';
|
||||
import { IconFileUpload, IconPhotoUp } from '@tabler/icons-react';
|
||||
import File from 'components/File';
|
||||
import { PlusIcon } from 'components/icons';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { usePaginatedFiles } from 'lib/queries/files';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import FilePagation from './FilePagation';
|
||||
import PendingFilesModal from './PendingFilesModal';
|
||||
|
||||
export default function Files({ disableMediaPreview, exifEnabled, queryPage }) {
|
||||
export default function Files({ disableMediaPreview, exifEnabled, queryPage, compress }) {
|
||||
const [favoritePage, setFavoritePage] = useState(1);
|
||||
const [favoriteNumPages, setFavoriteNumPages] = useState(0);
|
||||
const favoritePages = usePaginatedFiles(favoritePage, 'media', true);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { count } = await useFetch('/api/user/paged?count=true&filter=media&favorite=true');
|
||||
@@ -19,21 +22,21 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage }) {
|
||||
})();
|
||||
});
|
||||
|
||||
const updatePages = async (favorite) => {
|
||||
if (favorite) {
|
||||
favoritePages.refetch();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PendingFilesModal open={open} onClose={() => setOpen(false)} />
|
||||
|
||||
<Group mb='md'>
|
||||
<Title>Files</Title>
|
||||
<Link href='/dashboard/upload/file' passHref legacyBehavior>
|
||||
<ActionIcon component='a' variant='filled' color='primary'>
|
||||
<PlusIcon />
|
||||
<ActionIcon component={Link} href='/dashboard/upload/file' variant='filled' color='primary'>
|
||||
<IconFileUpload size='1rem' />
|
||||
</ActionIcon>
|
||||
|
||||
<Tooltip label='View pending uploads'>
|
||||
<ActionIcon onClick={() => setOpen(true)} variant='filled' color='primary'>
|
||||
<IconPhotoUp size='1rem' />
|
||||
</ActionIcon>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
{favoritePages.isSuccess && favoritePages.data.length ? (
|
||||
<Accordion
|
||||
@@ -55,6 +58,8 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage }) {
|
||||
image={image}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
exifEnabled={exifEnabled}
|
||||
refreshImages={favoritePages.refetch}
|
||||
onDash={compress}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
@@ -69,7 +74,7 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage }) {
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Pagination total={favoriteNumPages} page={favoritePage} onChange={setFavoritePage} />
|
||||
<Pagination total={favoriteNumPages} value={favoritePage} onChange={setFavoritePage} />
|
||||
</Box>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
@@ -80,6 +85,7 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage }) {
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
exifEnabled={exifEnabled}
|
||||
queryPage={queryPage}
|
||||
compress={compress}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
67
src/components/pages/Folders/CreateFolderModal.tsx
Normal file
67
src/components/pages/Folders/CreateFolderModal.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Button, Group, Modal, TextInput, Title } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconFolderPlus, IconFolderX } from '@tabler/icons-react';
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
export default function CreateFolderModal({ open, setOpen, updateFolders, createWithFile }) {
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const res = await useFetch('/api/user/folders', 'POST', {
|
||||
name: values.name,
|
||||
add: createWithFile ? [createWithFile] : undefined,
|
||||
});
|
||||
|
||||
if (res.error) {
|
||||
showNotification({
|
||||
title: 'Failed to create folder',
|
||||
message: res.error,
|
||||
icon: <IconFolderX size='1rem' />,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Created folder ' + res.name,
|
||||
message: createWithFile ? 'Added file to folder' : undefined,
|
||||
icon: <IconFolderPlus size='1rem' />,
|
||||
color: 'green',
|
||||
});
|
||||
|
||||
if (createWithFile) {
|
||||
router.push('/dashboard/folders');
|
||||
}
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
updateFolders();
|
||||
form.setValues({ name: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>Create Folder</Title>}>
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<TextInput label='Folder Name' placeholder='Folder Name' {...form.getInputProps('name')} />
|
||||
|
||||
{createWithFile && (
|
||||
<MutedText size='sm'>
|
||||
Creating this folder will add file with an ID of <b>{createWithFile}</b> to it automatically.
|
||||
</MutedText>
|
||||
)}
|
||||
|
||||
<Group position='right' mt='md'>
|
||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button type='submit'>Create</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
54
src/components/pages/Folders/ViewFolderFilesModal.tsx
Normal file
54
src/components/pages/Folders/ViewFolderFilesModal.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Center, Modal, SimpleGrid, Stack, Text, Title } from '@mantine/core';
|
||||
import File from 'components/File';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { useFolder } from 'lib/queries/folders';
|
||||
|
||||
export default function ViewFolderFilesModal({
|
||||
open,
|
||||
setOpen,
|
||||
folderId,
|
||||
disableMediaPreview,
|
||||
exifEnabled,
|
||||
compress,
|
||||
}) {
|
||||
if (!folderId) return null;
|
||||
|
||||
const folder = useFolder(folderId, true);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Title>View {folder.data?.name}'s files</Title>}
|
||||
size='xl'
|
||||
>
|
||||
{folder.isSuccess ? (
|
||||
<>
|
||||
{folder.data.files.length ? (
|
||||
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
|
||||
{folder.data.files.map((file) => (
|
||||
<File
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
key={file.id}
|
||||
image={file}
|
||||
exifEnabled={exifEnabled}
|
||||
refreshImages={folder.refetch}
|
||||
onDash={compress}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Center>
|
||||
<Stack>
|
||||
<Text align='center'>No files in this folder</Text>
|
||||
<MutedText size='sm'>
|
||||
Add files to {folder.data.name} by clicking a file in the Files tab and selecting a folder
|
||||
</MutedText>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
410
src/components/pages/Folders/index.tsx
Normal file
410
src/components/pages/Folders/index.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
import { ActionIcon, Avatar, Card, Group, SimpleGrid, Skeleton, Stack, Title, Tooltip } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconClipboardCheck,
|
||||
IconClipboardCopy,
|
||||
IconExternalLink,
|
||||
IconFiles,
|
||||
IconFolderCancel,
|
||||
IconFolderMinus,
|
||||
IconFolderPlus,
|
||||
IconFolderShare,
|
||||
IconFolderX,
|
||||
IconGridDots,
|
||||
IconList,
|
||||
IconLock,
|
||||
IconLockCancel,
|
||||
IconLockOpen,
|
||||
} from '@tabler/icons-react';
|
||||
import AnchorNext from 'components/AnchorNext';
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useFolders } from 'lib/queries/folders';
|
||||
import { listViewFoldersSelector } from 'lib/recoil/settings';
|
||||
import { relativeTime } from 'lib/utils/client';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import CreateFolderModal from './CreateFolderModal';
|
||||
import ViewFolderFilesModal from './ViewFolderFilesModal';
|
||||
|
||||
export default function Folders({ disableMediaPreview, exifEnabled, compress }) {
|
||||
const folders = useFolders();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createWithFile, setCreateWithFile] = useState(null);
|
||||
const [viewOpen, setViewOpen] = useState(false);
|
||||
const [activeFolderId, setActiveFolderId] = useState(null);
|
||||
|
||||
const modals = useModals();
|
||||
const clipboard = useClipboard();
|
||||
const router = useRouter();
|
||||
|
||||
const [listView, setListView] = useRecoilState(listViewFoldersSelector);
|
||||
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
|
||||
columnAccessor: 'updatedAt',
|
||||
direction: 'desc',
|
||||
});
|
||||
const [records, setRecords] = useState(folders.data);
|
||||
|
||||
useEffect(() => {
|
||||
setRecords(folders.data);
|
||||
}, [folders.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!records || records.length === 0) return;
|
||||
|
||||
const sortedRecords = [...records].sort((a, b) => {
|
||||
if (sortStatus.direction === 'asc') {
|
||||
return a[sortStatus.columnAccessor] > b[sortStatus.columnAccessor] ? 1 : -1;
|
||||
}
|
||||
|
||||
return a[sortStatus.columnAccessor] < b[sortStatus.columnAccessor] ? 1 : -1;
|
||||
});
|
||||
|
||||
setRecords(sortedRecords);
|
||||
}, [sortStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (router.query.create) {
|
||||
setCreateOpen(true);
|
||||
setCreateWithFile(router.query.create);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteFolder = (folder) => {
|
||||
modals.openConfirmModal({
|
||||
title: <Title>Delete folder {folder.name}?</Title>,
|
||||
children:
|
||||
'Are you sure you want to delete this folder? All files within the folder will still exist, but will no longer be in a folder.',
|
||||
labels: {
|
||||
confirm: 'Delete',
|
||||
cancel: 'Cancel',
|
||||
},
|
||||
onConfirm: async () => {
|
||||
const res = await useFetch(`/api/user/folders/${folder.id}`, 'DELETE', {
|
||||
deleteFolder: true,
|
||||
});
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Deleted folder',
|
||||
message: `Deleted folder ${folder.name}`,
|
||||
color: 'green',
|
||||
icon: <IconFolderMinus size='1rem' />,
|
||||
});
|
||||
folders.refetch();
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to delete folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <IconFolderCancel size='1rem' />,
|
||||
});
|
||||
folders.refetch();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const makePublic = async (folder) => {
|
||||
const res = await useFetch(`/api/user/folders/${folder.id}`, 'PATCH', {
|
||||
public: folder.public ? false : true,
|
||||
});
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Made folder public',
|
||||
message: `Made folder ${folder.name} ${folder.public ? 'private' : 'public'}`,
|
||||
color: 'green',
|
||||
icon: <IconLockOpen size='1rem' />,
|
||||
});
|
||||
folders.refetch();
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to make folder public/private',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <IconLockCancel size='1rem' />,
|
||||
});
|
||||
folders.refetch();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateFolderModal
|
||||
open={createOpen}
|
||||
setOpen={setCreateOpen}
|
||||
createWithFile={createWithFile}
|
||||
updateFolders={folders.refetch}
|
||||
/>
|
||||
<ViewFolderFilesModal
|
||||
open={viewOpen}
|
||||
setOpen={setViewOpen}
|
||||
folderId={activeFolderId}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
exifEnabled={exifEnabled}
|
||||
compress={compress}
|
||||
/>
|
||||
|
||||
<Group mb='md'>
|
||||
<Title>Folders</Title>
|
||||
<ActionIcon onClick={() => setCreateOpen(!createOpen)} component='a' variant='filled' color='primary'>
|
||||
<IconFolderPlus size='1rem' />
|
||||
</ActionIcon>
|
||||
<Tooltip label={listView ? 'Switch to grid view' : 'Switch to list view'}>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setListView(!listView)}>
|
||||
{listView ? <IconList size='1rem' /> : <IconGridDots size='1rem' />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{listView ? (
|
||||
<DataTable
|
||||
withBorder
|
||||
borderRadius='md'
|
||||
highlightOnHover
|
||||
verticalSpacing='sm'
|
||||
columns={[
|
||||
{ accessor: 'id', title: 'ID', sortable: true },
|
||||
{ accessor: 'name', sortable: true },
|
||||
|
||||
{
|
||||
accessor: 'public',
|
||||
sortable: true,
|
||||
render: (folder) => (folder.public ? 'Public' : 'Private'),
|
||||
},
|
||||
{
|
||||
accessor: 'createdAt',
|
||||
title: 'Created',
|
||||
sortable: true,
|
||||
render: (folder) => new Date(folder.createdAt).toLocaleString(),
|
||||
},
|
||||
{
|
||||
accessor: 'updatedAt',
|
||||
title: 'Last updated',
|
||||
sortable: true,
|
||||
render: (folder) => new Date(folder.updatedAt).toLocaleString(),
|
||||
},
|
||||
{
|
||||
accessor: 'actions',
|
||||
textAlignment: 'right',
|
||||
render: (folder) => (
|
||||
<Group spacing={4} position='right' noWrap>
|
||||
<Tooltip label='View files in folder'>
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
setViewOpen(true);
|
||||
setActiveFolderId(folder.id);
|
||||
}}
|
||||
variant='subtle'
|
||||
color='primary'
|
||||
>
|
||||
<IconFiles size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={folder.public ? 'Make folder private' : 'Make folder public'}>
|
||||
<ActionIcon onClick={() => makePublic(folder)} variant='subtle' color='primary'>
|
||||
{folder.public ? <IconLockOpen size='1rem' /> : <IconLock size='1rem' />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Open folder in new tab'>
|
||||
<ActionIcon
|
||||
onClick={() => window.open(`/folder/${folder.id}`, '_blank')}
|
||||
variant='subtle'
|
||||
color='primary'
|
||||
>
|
||||
<IconFolderShare size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Copy folder link'>
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
clipboard.copy(`${window.location.origin}/folder/${folder.id}`);
|
||||
showNotification({
|
||||
title: 'Copied folder link',
|
||||
message: 'Copied folder link to clipboard',
|
||||
color: 'green',
|
||||
icon: <IconClipboardCheck size='1rem' />,
|
||||
});
|
||||
}}
|
||||
variant='subtle'
|
||||
color='primary'
|
||||
>
|
||||
<IconClipboardCopy size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Delete folder'>
|
||||
<ActionIcon onClick={() => deleteFolder(folder)} variant='subtle' color='red'>
|
||||
<IconFolderX size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
records={records ?? []}
|
||||
fetching={folders.isLoading}
|
||||
loaderBackgroundBlur={5}
|
||||
minHeight='calc(100vh - 200px)'
|
||||
loaderVariant='dots'
|
||||
rowContextMenu={{
|
||||
shadow: 'xl',
|
||||
borderRadius: 'md',
|
||||
items: (folder) => [
|
||||
{
|
||||
key: 'viewFiles',
|
||||
title: 'View files in folder',
|
||||
icon: <IconFiles size='1rem' />,
|
||||
onClick: () => {
|
||||
setViewOpen(true);
|
||||
setActiveFolderId(folder.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'makePublic',
|
||||
title: folder.public ? 'Make folder private' : 'Make folder public',
|
||||
icon: folder.public ? <IconLockOpen size='1rem' /> : <IconLock size='1rem' />,
|
||||
onClick: () => makePublic(folder),
|
||||
},
|
||||
{
|
||||
key: 'openFolder',
|
||||
title: 'Open folder in a new tab',
|
||||
icon: <IconExternalLink size='1rem' />,
|
||||
onClick: () => window.open(`/folder/${folder.id}`, '_blank'),
|
||||
},
|
||||
{
|
||||
key: 'copyLink',
|
||||
title: 'Copy folder link to clipboard',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
onClick: () => {
|
||||
clipboard.copy(`${window.location.origin}/folder/${folder.id}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'deleteFolder',
|
||||
title: 'Delete folder',
|
||||
icon: <IconFolderX size='1rem' />,
|
||||
onClick: () => deleteFolder(folder),
|
||||
},
|
||||
],
|
||||
}}
|
||||
onCellClick={({ column, record: folder }) => {
|
||||
if (column.accessor === 'actions') return;
|
||||
|
||||
setViewOpen(true);
|
||||
setActiveFolderId(folder.id);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
|
||||
{folders.isSuccess
|
||||
? folders.data.length
|
||||
? folders.data.map((folder) => (
|
||||
<Card key={folder.id}>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Avatar size='lg' color='primary'>
|
||||
{folder.id}
|
||||
</Avatar>
|
||||
<Stack spacing={0}>
|
||||
<Title>{folder.name}</Title>
|
||||
<MutedText size='sm'>ID: {folder.id}</MutedText>
|
||||
<MutedText size='sm'>Public: {folder.public ? 'Yes' : 'No'}</MutedText>
|
||||
<Tooltip label={new Date(folder.createdAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>
|
||||
Created {relativeTime(new Date(folder.createdAt))}
|
||||
</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip label={new Date(folder.updatedAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>
|
||||
Last updated {relativeTime(new Date(folder.updatedAt))}
|
||||
</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group>
|
||||
<Stack>
|
||||
<Tooltip label={folder.public ? 'Make folder private' : 'Make folder public'}>
|
||||
<ActionIcon
|
||||
aria-label={folder.public ? 'make private' : 'make public'}
|
||||
onClick={() => makePublic(folder)}
|
||||
>
|
||||
{folder.public ? <IconLock size='1rem' /> : <IconLockOpen size='1rem' />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Delete folder'>
|
||||
<ActionIcon aria-label='delete' onClick={() => deleteFolder(folder)}>
|
||||
<IconFolderMinus size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<ActionIcon
|
||||
aria-label='view files'
|
||||
onClick={() => {
|
||||
setViewOpen(!viewOpen);
|
||||
setActiveFolderId(folder.id);
|
||||
}}
|
||||
>
|
||||
<IconFiles size='1rem' />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
aria-label='copy link'
|
||||
onClick={() => {
|
||||
clipboard.copy(`${window.location.origin}/folder/${folder.id}`);
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: 'Zipline is unable to copy to clipboard due to security reasons.',
|
||||
color: 'red',
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
title: 'Copied folder link',
|
||||
message: (
|
||||
<>
|
||||
Copied{' '}
|
||||
<AnchorNext href={`/folder/${folder.id}`}>folder link</AnchorNext> to
|
||||
clipboard
|
||||
</>
|
||||
),
|
||||
color: 'green',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconClipboardCopy size='1rem' />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
aria-label='open in new tab'
|
||||
onClick={() => window.open(`/folder/${folder.id}`)}
|
||||
>
|
||||
<IconFolderShare size='1rem' />
|
||||
</ActionIcon>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
: null
|
||||
: [1, 2, 3, 4].map((x) => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,12 +17,24 @@ import { useForm } from '@mantine/form';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { CopyIcon, CrossIcon, DeleteIcon, PlusIcon, TagIcon } from 'components/icons';
|
||||
import type { Invite } from '@prisma/client';
|
||||
import {
|
||||
IconClipboardCopy,
|
||||
IconGridDots,
|
||||
IconList,
|
||||
IconPlus,
|
||||
IconTag,
|
||||
IconTagOff,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { listViewInvitesSelector } from 'lib/recoil/settings';
|
||||
import { expireText, relativeTime } from 'lib/utils/client';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
const expires = ['30m', '1h', '6h', '12h', '1d', '3d', '5d', '7d', 'never'];
|
||||
|
||||
@@ -38,7 +50,7 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
|
||||
if (!expires.includes(values.expires)) return form.setFieldError('expires', 'Invalid expiration');
|
||||
if (values.count < 1 || values.count > 100)
|
||||
return form.setFieldError('count', 'Must be between 1 and 100');
|
||||
const expires_at =
|
||||
const expiresAt =
|
||||
values.expires === 'never'
|
||||
? null
|
||||
: new Date(
|
||||
@@ -57,7 +69,7 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
|
||||
setOpen(false);
|
||||
|
||||
const res = await useFetch('/api/auth/invite', 'POST', {
|
||||
expires_at,
|
||||
expiresAt,
|
||||
count: values.count,
|
||||
});
|
||||
|
||||
@@ -65,14 +77,14 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
|
||||
showNotification({
|
||||
title: 'Failed to create invite',
|
||||
message: res.error,
|
||||
icon: <CrossIcon />,
|
||||
icon: <IconTagOff size='1rem' />,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Created invite',
|
||||
message: '',
|
||||
icon: <TagIcon />,
|
||||
icon: <IconTag size='1rem' />,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
@@ -108,7 +120,7 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
|
||||
min={1}
|
||||
stepHoldDelay={200}
|
||||
stepHoldInterval={100}
|
||||
parser={(v: string) => Number(v.replace(/[^\d]/g, ''))}
|
||||
parser={(v: string) => v.replace(/[^\d]/g, '')}
|
||||
/>
|
||||
|
||||
<Group position='right' mt='md'>
|
||||
@@ -125,29 +137,56 @@ export default function Invites() {
|
||||
const modals = useModals();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const [invites, setInvites] = useState([]);
|
||||
const [invites, setInvites] = useState<Invite[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [ok, setOk] = useState(false);
|
||||
|
||||
const [listView, setListView] = useRecoilState(listViewInvitesSelector);
|
||||
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
|
||||
columnAccessor: 'createdAt',
|
||||
direction: 'asc',
|
||||
});
|
||||
const [records, setRecords] = useState(invites);
|
||||
|
||||
useEffect(() => {
|
||||
setRecords(invites);
|
||||
}, [invites]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!records || records.length === 0) return;
|
||||
|
||||
const sortedRecords = [...records].sort((a, b) => {
|
||||
if (sortStatus.direction === 'asc') {
|
||||
return a[sortStatus.columnAccessor] > b[sortStatus.columnAccessor] ? 1 : -1;
|
||||
}
|
||||
|
||||
return a[sortStatus.columnAccessor] < b[sortStatus.columnAccessor] ? 1 : -1;
|
||||
});
|
||||
|
||||
setRecords(sortedRecords);
|
||||
}, [sortStatus]);
|
||||
|
||||
const openDeleteModal = (invite) =>
|
||||
modals.openConfirmModal({
|
||||
title: `Delete ${invite.code}?`,
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
overlayProps: { blur: 3 },
|
||||
labels: { confirm: 'Yes', cancel: 'No' },
|
||||
onConfirm: async () => {
|
||||
const res = await useFetch(`/api/auth/invite?code=${invite.code}`, 'DELETE');
|
||||
if (res.error) {
|
||||
showNotification({
|
||||
title: 'Failed to delete invite ${invite.code}',
|
||||
title: `Failed to delete invite ${invite.code}`,
|
||||
message: res.error,
|
||||
icon: <CrossIcon />,
|
||||
icon: <IconTagOff size='1rem' />,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: `Deleted invite ${invite.code}`,
|
||||
message: '',
|
||||
icon: <DeleteIcon />,
|
||||
icon: <IconTag size='1rem' />,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
@@ -158,17 +197,25 @@ export default function Invites() {
|
||||
|
||||
const handleCopy = async (invite) => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}/auth/register?code=${invite.code}`);
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: 'Zipline is unable to copy to clipboard due to security reasons.',
|
||||
color: 'red',
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
};
|
||||
|
||||
const updateInvites = async () => {
|
||||
const us = await useFetch('/api/auth/invite');
|
||||
if (!us.error) {
|
||||
setInvites(us);
|
||||
setOk(true);
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
@@ -184,48 +231,134 @@ export default function Invites() {
|
||||
<Group mb='md'>
|
||||
<Title>Invites</Title>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}>
|
||||
<PlusIcon />
|
||||
<IconPlus size='1rem' />
|
||||
</ActionIcon>
|
||||
<Tooltip label={listView ? 'Switch to grid view' : 'Switch to list view'}>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setListView(!listView)}>
|
||||
{listView ? <IconList size='1rem' /> : <IconGridDots size='1rem' />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
|
||||
{invites.length
|
||||
? invites.map((invite) => (
|
||||
<Card key={invite.id} sx={{ maxWidth: '100%' }}>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Avatar size='lg' color={invite.used ? 'dark' : 'primary'}>
|
||||
{invite.id}
|
||||
</Avatar>
|
||||
<Stack spacing={0}>
|
||||
<Title>
|
||||
{invite.code}
|
||||
{invite.used && <> (Used)</>}
|
||||
</Title>
|
||||
<Tooltip label={new Date(invite.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>
|
||||
{listView ? (
|
||||
<DataTable
|
||||
withBorder
|
||||
borderRadius='md'
|
||||
highlightOnHover
|
||||
verticalSpacing='sm'
|
||||
columns={[
|
||||
{ accessor: 'id', sortable: true },
|
||||
{ accessor: 'code', sortable: true },
|
||||
{
|
||||
accessor: 'createdAt',
|
||||
title: 'Created At',
|
||||
sortable: true,
|
||||
render: (invite) => new Date(invite.createdAt).toLocaleString(),
|
||||
},
|
||||
{
|
||||
accessor: 'expiresAt',
|
||||
title: 'Expires At',
|
||||
sortable: true,
|
||||
render: (invite) => new Date(invite.expiresAt).toLocaleString(),
|
||||
},
|
||||
{
|
||||
accessor: 'used',
|
||||
sortable: true,
|
||||
render: (invite) => (invite.used ? 'Yes' : 'No'),
|
||||
},
|
||||
{
|
||||
accessor: 'actions',
|
||||
textAlignment: 'right',
|
||||
render: (invite) => (
|
||||
<Group spacing={4} position='right' noWrap>
|
||||
<Tooltip label='Copy invite link'>
|
||||
<ActionIcon variant='subtle' color='primary' onClick={() => handleCopy(invite)}>
|
||||
<IconClipboardCopy size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Delete invite'>
|
||||
<ActionIcon variant='subtle' color='red' onClick={() => openDeleteModal(invite)}>
|
||||
<IconTrash size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
records={records ?? []}
|
||||
fetching={!ok}
|
||||
minHeight='calc(100vh - 200px)'
|
||||
loaderBackgroundBlur={5}
|
||||
loaderVariant='dots'
|
||||
rowContextMenu={{
|
||||
shadow: 'xl',
|
||||
borderRadius: 'md',
|
||||
items: (invite) => [
|
||||
{
|
||||
key: 'copy',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
title: `Copy invite code: "${invite.code}"`,
|
||||
onClick: () => clipboard.copy(invite.code),
|
||||
},
|
||||
{
|
||||
key: 'copyLink',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
title: 'Copy invite link',
|
||||
onClick: () => handleCopy(invite),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
icon: <IconTrash size='1rem' />,
|
||||
title: `Delete invite ${invite.code}`,
|
||||
onClick: () => openDeleteModal(invite),
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
|
||||
{invites.length
|
||||
? invites.map((invite) => (
|
||||
<Card key={invite.id} sx={{ maxWidth: '100%' }}>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Avatar size='lg' color={invite.used ? 'dark' : 'primary'}>
|
||||
{invite.id}
|
||||
</Avatar>
|
||||
<Stack spacing={0}>
|
||||
<Title>
|
||||
{invite.code}
|
||||
{invite.used && <> (Used)</>}
|
||||
</Title>
|
||||
<Tooltip label={new Date(invite.createdAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>
|
||||
Created {relativeTime(new Date(invite.createdAt))}
|
||||
</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip label={new Date(invite.expiresAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>{expireText(invite.expiresAt.toString())}</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack>
|
||||
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
|
||||
<IconClipboardCopy size='1rem' />
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
|
||||
<IconTrash size='1rem' />
|
||||
</ActionIcon>
|
||||
</Stack>
|
||||
</Group>
|
||||
<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>
|
||||
</Card>
|
||||
))
|
||||
: [1, 2, 3].map((x) => <Skeleton key={x} width='100%' height={100} radius='sm' />)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
93
src/components/pages/Manage/ClearStorage.tsx
Normal file
93
src/components/pages/Manage/ClearStorage.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Button, Checkbox, Group, Modal, Text, Title } from '@mantine/core';
|
||||
import { closeAllModals, openConfirmModal } from '@mantine/modals';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import { IconFiles, IconFilesOff } from '@tabler/icons-react';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ClearStorage({ open, setOpen }) {
|
||||
const [check, setCheck] = useState(false);
|
||||
const handleDelete = async (datasource: boolean, orphaned?: boolean) => {
|
||||
showNotification({
|
||||
id: 'clear-uploads',
|
||||
title: 'Clearing...',
|
||||
message: '',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
const res = await useFetch('/api/admin/clear', 'POST', { datasource, orphaned });
|
||||
|
||||
if (res.error) {
|
||||
updateNotification({
|
||||
id: 'clear-uploads',
|
||||
title: 'Error while clearing uploads',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <IconFilesOff size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
updateNotification({
|
||||
id: 'clear-uploads',
|
||||
title: 'Successfully cleared uploads',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <IconFiles size='1rem' />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
setCheck(() => false);
|
||||
}}
|
||||
title={<Title size='sm'>Are you sure you want to clear all uploads in the database?</Title>}
|
||||
>
|
||||
<Checkbox
|
||||
id='orphanedFiles'
|
||||
label='Clear only orphaned files?'
|
||||
description='Orphaned files are not owned by anyone. They can't be seen the dashboard by anyone.'
|
||||
checked={check}
|
||||
onChange={(e) => setCheck(e.currentTarget.checked)}
|
||||
/>
|
||||
<Group position='right' mt='md'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setOpen(() => false);
|
||||
}}
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
openConfirmModal({
|
||||
title: 'Do you want to clear storage too?',
|
||||
labels: { confirm: 'Yes', cancel: check ? 'Ok' : 'No' },
|
||||
children: check && (
|
||||
<Text size='sm' color='gray'>
|
||||
Due to clearing orphaned files, storage clearing will be unavailable.
|
||||
</Text>
|
||||
),
|
||||
confirmProps: { disabled: check },
|
||||
onConfirm: () => {
|
||||
closeAllModals();
|
||||
handleDelete(true);
|
||||
},
|
||||
onCancel: () => {
|
||||
closeAllModals();
|
||||
handleDelete(false, check);
|
||||
},
|
||||
onClose: () => setCheck(false),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Code } from '@mantine/core';
|
||||
import Link from 'components/Link';
|
||||
import AnchorNext from 'components/AnchorNext';
|
||||
import { GeneratorModal } from './GeneratorModal';
|
||||
|
||||
export default function Flameshot({ user, open, setOpen }) {
|
||||
@@ -7,28 +7,31 @@ export default function Flameshot({ user, open, setOpen }) {
|
||||
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`,
|
||||
}/api/${values.type === 'upload-file' ? 'upload' : 'shorten'}`,
|
||||
];
|
||||
|
||||
if (values.type === 'upload-file') {
|
||||
curl.push('-F', 'file=@/tmp/ss.png');
|
||||
curl.push('-H', '"Content-Type: multipart/form-data"');
|
||||
} else {
|
||||
curl.push('-H', '"Content-Type: application/json"');
|
||||
}
|
||||
|
||||
const extraHeaders = {};
|
||||
|
||||
if (values.format !== 'RANDOM') {
|
||||
if (values.format !== 'RANDOM' && values.type === 'upload-file') {
|
||||
extraHeaders['Format'] = values.format;
|
||||
} else {
|
||||
delete extraHeaders['Format'];
|
||||
}
|
||||
|
||||
if (values.imageCompression !== 0) {
|
||||
if (values.imageCompression !== 0 && values.type === 'upload-file') {
|
||||
extraHeaders['Image-Compression-Percent'] = values.imageCompression;
|
||||
} else {
|
||||
delete extraHeaders['Image-Compression-Percent'];
|
||||
@@ -40,25 +43,55 @@ export default function Flameshot({ user, open, setOpen }) {
|
||||
delete extraHeaders['Zws'];
|
||||
}
|
||||
|
||||
if (values.embed) {
|
||||
if (values.embed && values.type === 'upload-file') {
|
||||
extraHeaders['Embed'] = 'true';
|
||||
} else {
|
||||
delete extraHeaders['Embed'];
|
||||
}
|
||||
|
||||
if (values.noJSON) {
|
||||
extraHeaders['No-JSON'] = 'true';
|
||||
} else {
|
||||
delete extraHeaders['No-JSON'];
|
||||
}
|
||||
|
||||
if (values.originalName && values.type === 'upload-file') {
|
||||
extraHeaders['Original-Name'] = 'true';
|
||||
} else {
|
||||
delete extraHeaders['Original-Name'];
|
||||
}
|
||||
|
||||
if (values.overrideDomain && values.overrideDomain.trim() !== '') {
|
||||
extraHeaders['Override-Domain'] = values.overrideDomain;
|
||||
} else {
|
||||
delete extraHeaders['Override-Domain'];
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(extraHeaders)) {
|
||||
curl.push('-H');
|
||||
curl.push(`"${key}: ${value}"`);
|
||||
}
|
||||
|
||||
const shell = `#!/bin/bash${values.wlCompositorNotSupported ? '\nexport XDG_CURRENT_DESKTOP=sway\n' : ''}
|
||||
flameshot gui -p /tmp/ss.png;
|
||||
${curl.join(' ')} | jq -r '.files[0]' | tr -d '\\n' | ${values.wlCompatibility ? 'wl-copy' : 'xsel -ib'};
|
||||
let shell;
|
||||
if (values.type === 'upload-file') {
|
||||
shell = `#!/bin/bash${values.wlCompositorNotSupported ? '\nexport XDG_CURRENT_DESKTOP=sway\n' : ''}
|
||||
flameshot gui -r > /tmp/ss.png;
|
||||
${curl.join(' ')}${values.noJSON ? '' : " | jq -r '.files[0]'"} | tr -d '\\n' | ${
|
||||
values.wlCompatibility ? 'wl-copy' : 'xsel -ib'
|
||||
};
|
||||
`;
|
||||
} else if (values.type === 'shorten-url') {
|
||||
shell = `#!/bin/bash
|
||||
arg=$1;
|
||||
${curl.join(' ')} -d "{\\"url\\": \\"$arg\\"}"${values.noJSON ? '' : " | jq -r '.url'"} | tr -d '\\n' | ${
|
||||
values.wlCompatibility ? 'wl-copy' : 'xsel -ib'
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
const pseudoElement = document.createElement('a');
|
||||
pseudoElement.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(shell));
|
||||
pseudoElement.setAttribute('download', 'zipline.sh');
|
||||
pseudoElement.setAttribute('download', `zipline${values.type === 'upload-file' ? '' : '-url'}.sh`);
|
||||
pseudoElement.style.display = 'none';
|
||||
document.body.appendChild(pseudoElement);
|
||||
pseudoElement.click();
|
||||
@@ -72,18 +105,18 @@ ${curl.join(' ')} | jq -r '.files[0]' | tr -d '\\n' | ${values.wlCompatibility ?
|
||||
title='Flameshot'
|
||||
desc={
|
||||
<>
|
||||
To use this script, you need <Link href='https://flameshot.org'>Flameshot</Link>,{' '}
|
||||
<Link href='https://curl.se/'>
|
||||
To use this script, you need <AnchorNext href='https://flameshot.org'>Flameshot</AnchorNext>,{' '}
|
||||
<AnchorNext href='https://curl.se/'>
|
||||
<Code>curl</Code>
|
||||
</Link>
|
||||
</AnchorNext>
|
||||
,{' '}
|
||||
<Link href='https://github.com/stedolan/jq'>
|
||||
<AnchorNext href='https://github.com/stedolan/jq'>
|
||||
<Code>jq</Code>
|
||||
</Link>
|
||||
</AnchorNext>
|
||||
, and{' '}
|
||||
<Link href='https://github.com/kfish/xsel'>
|
||||
<AnchorNext href='https://github.com/kfish/xsel'>
|
||||
<Code>xsel</Code>
|
||||
</Link>{' '}
|
||||
</AnchorNext>{' '}
|
||||
installed. This script is intended for use on Linux only.
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,21 +1,85 @@
|
||||
import { Box, Button, Checkbox, Code, Group, Modal, NumberInput, Select, Text, Title } from '@mantine/core';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Code,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { DownloadIcon } from 'components/icons';
|
||||
import Link from 'components/Link';
|
||||
import { IconFileDownload, IconWorld } from '@tabler/icons-react';
|
||||
import AnchorNext from 'components/AnchorNext';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { useReducer, useState } from 'react';
|
||||
|
||||
const DEFAULT_OD_DESC = 'Override the default domain(s). Type in a URL, e.g https://example.com';
|
||||
|
||||
export function GeneratorModal({ opened, onClose, title, onSubmit, ...other }) {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
type: 'upload-file',
|
||||
format: 'RANDOM',
|
||||
imageCompression: 0,
|
||||
zeroWidthSpace: false,
|
||||
embed: false,
|
||||
wlCompatibility: false,
|
||||
wlCompositorNotSupported: false,
|
||||
noJSON: false,
|
||||
originalName: false,
|
||||
overrideDomain: null,
|
||||
},
|
||||
});
|
||||
|
||||
const [isUploadFile, setIsUploadFile] = useState(true);
|
||||
|
||||
const onChangeType = (value) => {
|
||||
setIsUploadFile(value === 'upload-file');
|
||||
form.setFieldValue('type', value);
|
||||
};
|
||||
|
||||
const [odState, setODState] = useReducer((state, newState) => ({ ...state, ...newState }), {
|
||||
description: DEFAULT_OD_DESC,
|
||||
error: '',
|
||||
domain: '',
|
||||
});
|
||||
|
||||
const handleOD = (e) => {
|
||||
setODState({ error: '' });
|
||||
|
||||
if (e.currentTarget.value === '') {
|
||||
setODState({ description: DEFAULT_OD_DESC, error: '', domain: null });
|
||||
form.setFieldValue('overrideDomain', null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(e.currentTarget.value);
|
||||
setODState({
|
||||
description: (
|
||||
<>
|
||||
{DEFAULT_OD_DESC}
|
||||
<br />
|
||||
<br />
|
||||
Using domain "<b>{url.hostname}</b>"
|
||||
</>
|
||||
),
|
||||
error: '',
|
||||
domain: url.hostname,
|
||||
});
|
||||
form.setFieldValue('overrideDomain', url.hostname);
|
||||
} catch (e) {
|
||||
setODState({ error: 'Invalid URL', domain: '' });
|
||||
form.setFieldValue('overrideDomain', null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={<Title order={3}>{title}</Title>} size='lg'>
|
||||
{other.desc && (
|
||||
@@ -24,50 +88,95 @@ export function GeneratorModal({ opened, onClose, title, onSubmit, ...other }) {
|
||||
</MutedText>
|
||||
)}
|
||||
<form onSubmit={form.onSubmit((values) => onSubmit(values))}>
|
||||
<Select
|
||||
label='Type'
|
||||
data={[
|
||||
{ value: 'upload-file', label: 'Upload file' },
|
||||
{ value: 'shorten-url', label: 'Shorten URLs' },
|
||||
]}
|
||||
id='type'
|
||||
my='sm'
|
||||
{...form.getInputProps('type')}
|
||||
onChange={onChangeType}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label='Select file name format'
|
||||
data={[
|
||||
{ value: 'RANDOM', label: 'Random (alphanumeric)' },
|
||||
{ value: 'DATE', label: 'Date' },
|
||||
{ value: 'UUID', label: 'UUID' },
|
||||
{ value: 'NAME', label: 'Name (keeps original file name)' },
|
||||
{ value: 'random', label: 'Random (alphanumeric)' },
|
||||
{ value: 'date', label: 'Date' },
|
||||
{ value: 'uuid', label: 'UUID' },
|
||||
{ value: 'name', label: 'Name (keeps original file name)' },
|
||||
{ value: 'gfycat', label: 'Gfycat' },
|
||||
]}
|
||||
id='format'
|
||||
my='sm'
|
||||
disabled={!isUploadFile}
|
||||
{...form.getInputProps('format')}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={"Image Compression (leave at 0 if you don't want to compress)"}
|
||||
label='Image Compression'
|
||||
description='Set the image compression level (0-100). 0 is no compression, 100 is maximum compression.'
|
||||
max={100}
|
||||
min={0}
|
||||
mt='md'
|
||||
my='sm'
|
||||
id='imageCompression'
|
||||
disabled={!isUploadFile}
|
||||
{...form.getInputProps('imageCompression')}
|
||||
/>
|
||||
|
||||
<Group grow mt='md'>
|
||||
<Checkbox
|
||||
<TextInput
|
||||
label='Override Domain'
|
||||
onChange={handleOD}
|
||||
icon={<IconWorld size='1rem' />}
|
||||
description={odState.description}
|
||||
error={odState.error}
|
||||
/>
|
||||
|
||||
<Stack my='md'>
|
||||
<Switch
|
||||
label='Zero Width Space'
|
||||
description='Use zero width spaces as the file name'
|
||||
id='zeroWidthSpace'
|
||||
{...form.getInputProps('zeroWidthSpace', { type: 'checkbox' })}
|
||||
/>
|
||||
<Checkbox label='Embed' id='embed' {...form.getInputProps('embed', { type: 'checkbox' })} />
|
||||
</Group>
|
||||
<Switch
|
||||
description='Return response as plain text instead of JSON'
|
||||
label='No JSON'
|
||||
id='noJSON'
|
||||
{...form.getInputProps('noJSON', { type: 'checkbox' })}
|
||||
/>
|
||||
<Switch
|
||||
description='Image will display with embedded metadata'
|
||||
label='Embed'
|
||||
id='embed'
|
||||
disabled={!isUploadFile}
|
||||
{...form.getInputProps('embed', { type: 'checkbox' })}
|
||||
/>
|
||||
<Switch
|
||||
description='Whether or not to show the original name when downloading this specific file. This will not change the name format in the URL.'
|
||||
label='Original Name'
|
||||
id='originalName'
|
||||
disabled={!isUploadFile}
|
||||
{...form.getInputProps('originalName', { type: 'checkbox' })}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{title === 'Flameshot' && (
|
||||
<>
|
||||
<Box mt='md'>
|
||||
<Box my='md'>
|
||||
<Text>Wayland</Text>
|
||||
<MutedText size='sm'>
|
||||
If using wayland, you can check the boxes below to your liking. This will require{' '}
|
||||
<Link href='https://github.com/bugaevc/wl-clipboard'>
|
||||
<AnchorNext href='https://github.com/bugaevc/wl-clipboard'>
|
||||
<Code>wl-clipboard</Code>
|
||||
</Link>{' '}
|
||||
</AnchorNext>{' '}
|
||||
for the <Code>wl-copy</Code> command.
|
||||
</MutedText>
|
||||
</Box>
|
||||
|
||||
<Group mt='md'>
|
||||
<Group my='md'>
|
||||
<Checkbox
|
||||
label='Enable Wayland Compatibility'
|
||||
description={
|
||||
@@ -88,10 +197,11 @@ export function GeneratorModal({ opened, onClose, title, onSubmit, ...other }) {
|
||||
description={
|
||||
<>
|
||||
If using a compositor such as{' '}
|
||||
<Link href='https://github.com/hyprwm/hyprland'>Hyprland</Link>, this option will set the{' '}
|
||||
<Code>XDG_CURRENT_DESKTOP=sway</Code> to workaround Flameshot's errors
|
||||
<AnchorNext href='https://github.com/hyprwm/hyprland'>Hyprland</AnchorNext>, this option
|
||||
will set the <Code>XDG_CURRENT_DESKTOP=sway</Code> to workaround Flameshot's errors
|
||||
</>
|
||||
}
|
||||
disabled={!isUploadFile}
|
||||
id='wlCompositorNotSupported'
|
||||
{...form.getInputProps('wlCompositorNotSupported', { type: 'checkbox' })}
|
||||
/>
|
||||
@@ -99,12 +209,10 @@ export function GeneratorModal({ opened, onClose, title, onSubmit, ...other }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group grow>
|
||||
<Button mt='md' onClick={form.reset}>
|
||||
Reset
|
||||
</Button>
|
||||
<Group grow my='md'>
|
||||
<Button onClick={form.reset}>Reset</Button>
|
||||
|
||||
<Button mt='md' rightIcon={<DownloadIcon />} type='submit'>
|
||||
<Button rightIcon={<IconFileDownload size='1rem' />} type='submit'>
|
||||
Download
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1,57 +1,87 @@
|
||||
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);
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
const config = {
|
||||
Version: '14.1.0',
|
||||
Name: `Zipline - ${hostname} - ${values.type === 'upload-file' ? 'File' : 'URL'}`,
|
||||
DestinationType: 'ImageUploader, TextUploader, FileUploader',
|
||||
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',
|
||||
Data: undefined,
|
||||
};
|
||||
|
||||
if (values.type === 'shorten-url') {
|
||||
config.RequestURL = `${
|
||||
window.location.protocol +
|
||||
'//' +
|
||||
window.location.hostname +
|
||||
(window.location.port ? ':' + window.location.port : '')
|
||||
}/api/shorten`;
|
||||
config.URL = '{json:url}';
|
||||
config.Body = 'JSON';
|
||||
config.DestinationType = 'URLShortener,URLSharingService';
|
||||
delete config.FileFormName;
|
||||
config.Data = JSON.stringify({ url: '{input}' });
|
||||
} else {
|
||||
delete config.Headers['Format'];
|
||||
setConfig(config);
|
||||
delete config.Data;
|
||||
}
|
||||
|
||||
if (values.imageCompression !== 0) {
|
||||
if (values.format !== 'RANDOM' && values.type === 'upload-file') {
|
||||
config.Headers['Format'] = values.format;
|
||||
} else {
|
||||
delete config.Headers['Format'];
|
||||
}
|
||||
|
||||
if (values.imageCompression !== 0 && values.type === 'upload-file') {
|
||||
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) {
|
||||
if (values.embed && values.type === 'upload-file') {
|
||||
config.Headers['Embed'] = 'true';
|
||||
setConfig(config);
|
||||
} else {
|
||||
delete config.Headers['Embed'];
|
||||
setConfig(config);
|
||||
}
|
||||
|
||||
if (values.noJSON) {
|
||||
config.URL = '{response}';
|
||||
config.Headers['No-JSON'] = 'true';
|
||||
} else {
|
||||
delete config.Headers['No-JSON'];
|
||||
config.URL = values.type === 'upload-file' ? '{json:files[0]}' : '{json:url}';
|
||||
}
|
||||
|
||||
if (values.originalName && values.type === 'upload-file') {
|
||||
config.Headers['Original-Name'] = 'true';
|
||||
} else {
|
||||
delete config.Headers['Original-Name'];
|
||||
}
|
||||
|
||||
if (values.overrideDomain && values.overrideDomain.trim() !== '') {
|
||||
config.Headers['Override-Domain'] = values.overrideDomain;
|
||||
} else {
|
||||
delete config.Headers['Override-Domain'];
|
||||
}
|
||||
|
||||
const pseudoElement = document.createElement('a');
|
||||
@@ -59,7 +89,7 @@ export default function ShareX({ user, open, setOpen }) {
|
||||
'href',
|
||||
'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(config, null, '\t'))
|
||||
);
|
||||
pseudoElement.setAttribute('download', 'zipline.sxcu');
|
||||
pseudoElement.setAttribute('download', `zipline${values.type === 'upload-file' ? '' : '-url'}.sxcu`);
|
||||
pseudoElement.style.display = 'none';
|
||||
document.body.appendChild(pseudoElement);
|
||||
pseudoElement.click();
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Button, Center, Image, Modal, NumberInput, Text, Title } from '@mantine/core';
|
||||
import { Button, Center, Image, Modal, PinInput, Text, Title } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { CheckIcon, CrossIcon } from 'components/icons';
|
||||
import { Icon2fa, IconBarcodeOff, IconCheck } from '@tabler/icons-react';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
|
||||
export function TotpModal({ opened, onClose, deleteTotp, setUser }) {
|
||||
const [secret, setSecret] = useState('');
|
||||
const [qrCode, setQrCode] = useState('');
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [code, setCode] = useState(undefined);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -21,7 +20,7 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
|
||||
title: 'Error',
|
||||
message: "Can't generate code as you are already using MFA",
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
icon: <IconBarcodeOff size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
setSecret(data.secret);
|
||||
@@ -32,15 +31,15 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
|
||||
})();
|
||||
}, [opened]);
|
||||
|
||||
const disableTotp = async () => {
|
||||
const disableTotp = async (code) => {
|
||||
setDisabled(true);
|
||||
const str = code.toString();
|
||||
if (str.length !== 6) {
|
||||
if (code.length !== 6) {
|
||||
setDisabled(false);
|
||||
return setError('Code must be 6 digits');
|
||||
}
|
||||
|
||||
const resp = await useFetch('/api/user/mfa/totp', 'DELETE', {
|
||||
code: str,
|
||||
code,
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
@@ -48,29 +47,28 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Success',
|
||||
message: 'Successfully disabled MFA',
|
||||
message: 'Successfully disabled 2FA',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
icon: <Icon2fa size='1rem' />,
|
||||
});
|
||||
|
||||
setTotpEnabled(false);
|
||||
|
||||
setUser((user) => ({ ...user, totpSecret: null }));
|
||||
onClose();
|
||||
}
|
||||
|
||||
setDisabled(false);
|
||||
};
|
||||
|
||||
const verifyCode = async () => {
|
||||
const verifyCode = async (code) => {
|
||||
setDisabled(true);
|
||||
const str = code.toString();
|
||||
if (str.length !== 6) {
|
||||
if (code.length !== 6) {
|
||||
setDisabled(false);
|
||||
return setError('Code must be 6 digits');
|
||||
}
|
||||
|
||||
const resp = await useFetch('/api/user/mfa/totp', 'POST', {
|
||||
secret,
|
||||
code: str,
|
||||
code,
|
||||
register: true,
|
||||
});
|
||||
|
||||
@@ -79,19 +77,25 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Success',
|
||||
message: 'Successfully enabled MFA',
|
||||
message: 'Successfully enabled 2FA',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
icon: <Icon2fa size='1rem' />,
|
||||
});
|
||||
|
||||
setTotpEnabled(true);
|
||||
|
||||
setUser((user) => ({ ...user, totpSecret: secret }));
|
||||
onClose();
|
||||
}
|
||||
|
||||
setDisabled(false);
|
||||
};
|
||||
|
||||
const handlePinChange = (value) => {
|
||||
if (value.length === 6) {
|
||||
setDisabled(true);
|
||||
deleteTotp ? disableTotp(value) : verifyCode(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
@@ -110,29 +114,43 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
|
||||
<Center>
|
||||
<Image height={180} width={180} src={qrCode} alt='QR Code' withPlaceholder />
|
||||
</Center>
|
||||
<Text my='sm'>QR Code not working? Try manually entering the code into your app: {secret}</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
placeholder='2FA Code'
|
||||
label='Verify'
|
||||
size='xl'
|
||||
hideControls
|
||||
maxLength={6}
|
||||
minLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e)}
|
||||
error={error}
|
||||
/>
|
||||
<Center my='md'>
|
||||
<PinInput
|
||||
data-autofocus
|
||||
length={6}
|
||||
oneTimeCode
|
||||
type='number'
|
||||
placeholder=''
|
||||
onChange={handlePinChange}
|
||||
autoFocus={true}
|
||||
error={!!error}
|
||||
disabled={disabled}
|
||||
size='xl'
|
||||
/>
|
||||
</Center>
|
||||
|
||||
{error && (
|
||||
<Text my='sm' size='sm' color='red' align='center'>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!deleteTotp && (
|
||||
<Text my='sm' size='sm' color='gray' align='center'>
|
||||
QR Code not working? Try manually entering the code into your app: {secret}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
disabled={disabled}
|
||||
size='lg'
|
||||
fullWidth
|
||||
mt='md'
|
||||
rightIcon={<CheckIcon />}
|
||||
onClick={deleteTotp ? disableTotp : verifyCode}
|
||||
rightIcon={<IconCheck size='1rem' />}
|
||||
type='submit'
|
||||
>
|
||||
Verify{deleteTotp ? ' and Disable' : ''}
|
||||
</Button>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Group,
|
||||
Image,
|
||||
PasswordInput,
|
||||
SimpleGrid,
|
||||
Space,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -15,32 +16,40 @@ import {
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { randomId, useInterval } from '@mantine/hooks';
|
||||
import { randomId, useInterval, useMediaQuery } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import {
|
||||
CheckIcon,
|
||||
CrossIcon,
|
||||
DeleteIcon,
|
||||
DiscordIcon,
|
||||
FlameshotIcon,
|
||||
GitHubIcon,
|
||||
GoogleIcon,
|
||||
RefreshIcon,
|
||||
SettingsIcon,
|
||||
ShareXIcon,
|
||||
} from 'components/icons';
|
||||
import DownloadIcon from 'components/icons/DownloadIcon';
|
||||
import TrashIcon from 'components/icons/TrashIcon';
|
||||
import Link from 'components/Link';
|
||||
IconBrandDiscordFilled,
|
||||
IconBrandGithubFilled,
|
||||
IconBrandGoogle,
|
||||
IconFileExport,
|
||||
IconFiles,
|
||||
IconFilesOff,
|
||||
IconFileZip,
|
||||
IconGraph,
|
||||
IconGraphOff,
|
||||
IconPhotoMinus,
|
||||
IconReload,
|
||||
IconTrash,
|
||||
IconUserCheck,
|
||||
IconUserCog,
|
||||
IconUserExclamation,
|
||||
IconUserMinus,
|
||||
IconUserX,
|
||||
} from '@tabler/icons-react';
|
||||
import AnchorNext from 'components/AnchorNext';
|
||||
import { FlameshotIcon, ShareXIcon } from 'components/icons';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { SmallTable } from 'components/SmallTable';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { bytesToHuman } from 'lib/utils/bytes';
|
||||
import { capitalize } from 'lib/utils/client';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import ClearStorage from './ClearStorage';
|
||||
import Flameshot from './Flameshot';
|
||||
import ShareX from './ShareX';
|
||||
import { TotpModal } from './TotpModal';
|
||||
@@ -60,9 +69,9 @@ function ExportDataTooltip({ children }) {
|
||||
export default function Manage({ oauth_registration, oauth_providers: raw_oauth_providers, totp_enabled }) {
|
||||
const oauth_providers = JSON.parse(raw_oauth_providers);
|
||||
const icons = {
|
||||
Discord: DiscordIcon,
|
||||
GitHub: GitHubIcon,
|
||||
Google: GoogleIcon,
|
||||
Discord: IconBrandDiscordFilled,
|
||||
GitHub: IconBrandGithubFilled,
|
||||
Google: IconBrandGoogle,
|
||||
};
|
||||
|
||||
for (const provider of oauth_providers) {
|
||||
@@ -75,8 +84,9 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
const [totpOpen, setTotpOpen] = useState(false);
|
||||
const [shareXOpen, setShareXOpen] = useState(false);
|
||||
const [flameshotOpen, setFlameshotOpen] = useState(false);
|
||||
const [clrStorOpen, setClrStorOpen] = useState(false);
|
||||
const [exports, setExports] = useState([]);
|
||||
const [file, setFile] = useState<File>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [fileDataURL, setFileDataURL] = useState(user.avatar ?? null);
|
||||
const [totpEnabled, setTotpEnabled] = useState(!!user.totpSecret);
|
||||
|
||||
@@ -99,11 +109,13 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
const handleAvatarChange = async (file: File) => {
|
||||
setFile(file);
|
||||
|
||||
setFileDataURL(await getDataURL(file));
|
||||
if (file) setFileDataURL(await getDataURL(file));
|
||||
};
|
||||
|
||||
const saveAvatar = async () => {
|
||||
const dataURL = await getDataURL(file);
|
||||
let dataURL = null;
|
||||
|
||||
if (file) dataURL = await getDataURL(file);
|
||||
|
||||
showNotification({
|
||||
id: 'update-user',
|
||||
@@ -115,6 +127,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
|
||||
const newUser = await useFetch('/api/user', 'PATCH', {
|
||||
avatar: dataURL,
|
||||
...(!dataURL && { resetAvatar: true }),
|
||||
});
|
||||
|
||||
if (newUser.error) {
|
||||
@@ -123,7 +136,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
title: "Couldn't save user",
|
||||
message: newUser.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
icon: <IconUserX size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
setUser(newUser);
|
||||
@@ -131,6 +144,8 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
id: 'update-user',
|
||||
title: 'Saved User',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <IconUserCheck size='1rem' />,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -139,9 +154,10 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
initialValues: {
|
||||
username: user.username,
|
||||
password: '',
|
||||
embedTitle: user.embedTitle ?? '',
|
||||
embedColor: user.embedColor,
|
||||
embedSiteName: user.embedSiteName ?? '',
|
||||
embedTitle: user.embed?.title ?? null,
|
||||
embedColor: user.embed?.color ?? '',
|
||||
embedSiteName: user.embed?.siteName ?? null,
|
||||
embedDescription: user.embed?.description ?? null,
|
||||
domains: user.domains.join(','),
|
||||
},
|
||||
});
|
||||
@@ -149,9 +165,12 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
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();
|
||||
const cleanEmbed = {
|
||||
title: values.embedTitle ? values.embedTitle.trim() : null,
|
||||
color: values.embedColor !== '' ? values.embedColor.trim() : null,
|
||||
siteName: values.embedSiteName ? values.embedSiteName.trim() : null,
|
||||
description: values.embedDescription ? values.embedDescription.trim() : null,
|
||||
};
|
||||
|
||||
if (cleanUsername === '') return form.setFieldError('username', "Username can't be nothing");
|
||||
|
||||
@@ -166,13 +185,11 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
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 !== ''),
|
||||
embed: cleanEmbed,
|
||||
};
|
||||
|
||||
const newUser = await useFetch('/api/user', 'PATCH', data);
|
||||
@@ -195,7 +212,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
</>
|
||||
),
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
icon: <IconUserX size='1rem' />,
|
||||
});
|
||||
}
|
||||
updateNotification({
|
||||
@@ -203,7 +220,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
title: "Couldn't save user",
|
||||
message: newUser.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
icon: <IconUserX size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
setUser(newUser);
|
||||
@@ -211,6 +228,8 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
id: 'update-user',
|
||||
title: 'Saved User',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <IconUserCheck size='1rem' />,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -229,7 +248,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
title: 'Error exporting data',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
icon: <IconFileExport size='1rem' />,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -258,14 +277,14 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
title: "Couldn't delete files",
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
icon: <IconFilesOff size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Deleted files',
|
||||
message: `${res.count} files deleted`,
|
||||
color: 'green',
|
||||
icon: <DeleteIcon />,
|
||||
icon: <IconFiles size='1rem' />,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -297,66 +316,14 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
title: 'Error updating stats',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
icon: <IconGraphOff size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Updated stats',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openClearData = () => {
|
||||
modals.openConfirmModal({
|
||||
title: 'Are you sure you want to clear all uploads in the database?',
|
||||
closeOnConfirm: false,
|
||||
labels: { confirm: 'Yes', cancel: 'No' },
|
||||
onConfirm: () => {
|
||||
modals.openConfirmModal({
|
||||
title: 'Do you want to clear storage too?',
|
||||
labels: { confirm: 'Yes', cancel: 'No' },
|
||||
onConfirm: () => {
|
||||
handleClearData(true);
|
||||
modals.closeAll();
|
||||
},
|
||||
onCancel: () => {
|
||||
handleClearData(false);
|
||||
modals.closeAll();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearData = async (datasource?: boolean) => {
|
||||
showNotification({
|
||||
id: 'clear-uploads',
|
||||
title: 'Clearing...',
|
||||
message: '',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
const res = await useFetch('/api/admin/clear', 'POST', { datasource });
|
||||
|
||||
if (res.error) {
|
||||
updateNotification({
|
||||
id: 'clear-uploads',
|
||||
title: 'Error while clearing uploads',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
} else {
|
||||
updateNotification({
|
||||
id: 'clear-uploads',
|
||||
title: 'Successfully cleared uploads',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
icon: <IconGraph size='1rem' />,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -370,7 +337,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
title: 'Error while unlinking from OAuth',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
icon: <IconUserExclamation size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
setUser(res);
|
||||
@@ -378,7 +345,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
title: `Unlinked from ${provider[0] + provider.slice(1).toLowerCase()}`,
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
icon: <IconUserMinus size='1rem' />,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -387,14 +354,16 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
useEffect(() => {
|
||||
getExports();
|
||||
interval.start();
|
||||
}, []);
|
||||
setTotpEnabled(() => !!user.totpSecret);
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Manage User</Title>
|
||||
<MutedText size='md'>
|
||||
Want to use variables in embed text? Visit{' '}
|
||||
<Link href='https://zipline.diced.tech/docs/guides/variables'>the docs</Link> for variables
|
||||
<AnchorNext href='https://zipline.diced.tech/docs/guides/variables'>the docs</AnchorNext> for
|
||||
variables
|
||||
</MutedText>
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<TextInput id='username' label='Username' my='sm' {...form.getInputProps('username')} />
|
||||
@@ -405,14 +374,31 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
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')}
|
||||
/>
|
||||
|
||||
<SimpleGrid
|
||||
cols={4}
|
||||
breakpoints={[
|
||||
{ maxWidth: 768, cols: 1 },
|
||||
{ minWidth: 769, maxWidth: 1024, cols: 2 },
|
||||
{ minWidth: 1281, cols: 4 },
|
||||
]}
|
||||
>
|
||||
<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='embedDescription'
|
||||
label='Embed Description'
|
||||
my='sm'
|
||||
{...form.getInputProps('embedDescription')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
id='domains'
|
||||
label='Domains'
|
||||
@@ -423,7 +409,18 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
/>
|
||||
|
||||
<Group position='right' mt='md'>
|
||||
<Button type='submit'>Save User</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
size='lg'
|
||||
my='sm'
|
||||
sx={{
|
||||
'@media screen and (max-width: 768px)': {
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
|
||||
@@ -431,12 +428,21 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
<Box my='md'>
|
||||
<Title>Two Factor Authentication</Title>
|
||||
<MutedText size='md'>
|
||||
{user.totpSecret
|
||||
{totpEnabled
|
||||
? 'You have two factor authentication enabled.'
|
||||
: 'You do not have two factor authentication enabled.'}
|
||||
</MutedText>
|
||||
|
||||
<Button size='lg' my='sm' onClick={() => setTotpOpen(true)}>
|
||||
<Button
|
||||
size='lg'
|
||||
my='sm'
|
||||
onClick={() => setTotpOpen(true)}
|
||||
sx={{
|
||||
'@media screen and (max-width: 768px)': {
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{totpEnabled ? 'Disable' : 'Enable'} Two Factor Authentication
|
||||
</Button>
|
||||
|
||||
@@ -444,7 +450,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
opened={totpOpen}
|
||||
onClose={() => setTotpOpen(false)}
|
||||
deleteTotp={totpEnabled}
|
||||
setTotpEnabled={setTotpEnabled}
|
||||
setUser={setUser}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -461,11 +467,9 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
!user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase())
|
||||
)
|
||||
.map(({ link_url, name, Icon }, i) => (
|
||||
<Link key={i} href={link_url} passHref legacyBehavior>
|
||||
<Button size='lg' leftIcon={<Icon colorScheme='manage' />} component='a' my='sm'>
|
||||
Link account with {name}
|
||||
</Button>
|
||||
</Link>
|
||||
<Button key={i} size='lg' leftIcon={<Icon />} component={Link} href={link_url} my='sm'>
|
||||
Link account with {name}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{user?.oauth?.map(({ provider }, i) => (
|
||||
@@ -473,7 +477,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
key={i}
|
||||
onClick={() => handleOauthUnlink(provider)}
|
||||
size='lg'
|
||||
leftIcon={<TrashIcon />}
|
||||
leftIcon={<IconTrash size='1rem' />}
|
||||
my='sm'
|
||||
color='red'
|
||||
>
|
||||
@@ -497,21 +501,24 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
<Card mt='md'>
|
||||
<Text>Preview:</Text>
|
||||
<Button
|
||||
leftIcon={fileDataURL ? <Image src={fileDataURL} height={32} radius='md' /> : <SettingsIcon />}
|
||||
sx={(t) => ({
|
||||
backgroundColor: '#00000000',
|
||||
'&:hover': {
|
||||
backgroundColor: t.other.hover,
|
||||
},
|
||||
})}
|
||||
leftIcon={
|
||||
fileDataURL ? (
|
||||
<Image src={fileDataURL} height={32} width={32} radius='md' />
|
||||
) : (
|
||||
<IconUserCog size='1rem' />
|
||||
)
|
||||
}
|
||||
size='xl'
|
||||
p='sm'
|
||||
variant='subtle'
|
||||
color='gray'
|
||||
compact
|
||||
>
|
||||
{user.username}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Group position='right' mt='md'>
|
||||
<Group position='right' my='md' grow={useMediaQuery('(max-width: 768px)')}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setFile(null);
|
||||
@@ -525,21 +532,21 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Box mb='md'>
|
||||
<Box my='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'>
|
||||
<Group my='md' grow={useMediaQuery('(max-width: 768px)')}>
|
||||
<Button onClick={openDeleteModal} rightIcon={<IconPhotoMinus size='1rem' />} color='red'>
|
||||
Delete All Data
|
||||
</Button>
|
||||
<ExportDataTooltip>
|
||||
<Button onClick={exportData} rightIcon={<DownloadIcon />}>
|
||||
<Button onClick={exportData} rightIcon={<IconFileZip size='1rem' />}>
|
||||
Export Data
|
||||
</Button>
|
||||
</ExportDataTooltip>
|
||||
<Button onClick={getExports} rightIcon={<RefreshIcon />}>
|
||||
<Button onClick={getExports} rightIcon={<IconReload size='1rem' />}>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -573,11 +580,16 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
{user.administrator && (
|
||||
<Box mt='md'>
|
||||
<Title>Server</Title>
|
||||
<Group>
|
||||
<Button size='md' onClick={forceUpdateStats} color='red' rightIcon={<RefreshIcon />}>
|
||||
<Group my='md' grow={useMediaQuery('(max-width: 768px)')}>
|
||||
<Button size='md' onClick={forceUpdateStats} color='red' rightIcon={<IconReload size='1rem' />}>
|
||||
Force Update Stats
|
||||
</Button>
|
||||
<Button size='md' onClick={openClearData} color='red' rightIcon={<TrashIcon />}>
|
||||
<Button
|
||||
size='md'
|
||||
onClick={() => setClrStorOpen(true)}
|
||||
color='red'
|
||||
rightIcon={<IconTrash size='1rem' />}
|
||||
>
|
||||
Delete all uploads
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -586,16 +598,35 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
|
||||
<Title my='md'>Uploaders</Title>
|
||||
<Group>
|
||||
<Button size='xl' onClick={() => setShareXOpen(true)} rightIcon={<ShareXIcon />}>
|
||||
<Button
|
||||
size='xl'
|
||||
onClick={() => setShareXOpen(true)}
|
||||
rightIcon={<ShareXIcon size='1rem' />}
|
||||
sx={{
|
||||
'@media screen and (max-width: 768px)': {
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Generate ShareX Config
|
||||
</Button>
|
||||
<Button size='xl' onClick={() => setFlameshotOpen(true)} rightIcon={<FlameshotIcon />}>
|
||||
<Button
|
||||
size='xl'
|
||||
onClick={() => setFlameshotOpen(true)}
|
||||
rightIcon={<FlameshotIcon size='1rem' />}
|
||||
sx={{
|
||||
'@media screen and (max-width: 768px)': {
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Generate Flameshot Script
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<ShareX user={user} open={shareXOpen} setOpen={setShareXOpen} />
|
||||
<Flameshot user={user} open={flameshotOpen} setOpen={setFlameshotOpen} />
|
||||
<ClearStorage open={clrStorOpen} setOpen={setClrStorOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user