mirror of
https://github.com/diced/zipline.git
synced 2026-06-30 09:36:34 -07:00
Compare commits
159 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7df4a578b | |||
| 38e30b2525 | |||
| 8e44b71614 | |||
| 11bca28ef5 | |||
| 4ef0c6021a | |||
| 4fbbd58ae9 | |||
| 81dea6cf90 | |||
| 9b57fb280b | |||
| e804d0b31e | |||
| 76845fc7e4 | |||
| decd7f7918 | |||
| 8c5ff4f230 | |||
| 535600edc8 | |||
| 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 | |||
| 4e70daa4d8 | |||
| bb28f49cf5 | |||
| d85211a145 | |||
| a7291d374d | |||
| 5c9b558ac2 | |||
| 36ede22d45 | |||
| 6528ec4056 | |||
| 56ee494c7d | |||
| b21995a0b9 | |||
| 3c00575ecd | |||
| 27ccbcb54a | |||
| fecbf394c1 | |||
| 91341e2d21 | |||
| 6349503b00 | |||
| 58e8c103b7 | |||
| 5d115afa71 | |||
| d8b308a18c | |||
| 76267c00d5 | |||
| 9648856052 | |||
| d87e465a8e | |||
| 2c07d6719e | |||
| 4c633eb60d | |||
| ba6580e4ef | |||
| c21d8f837e | |||
| eadfa09570 | |||
| ea1a0b7fc8 | |||
| 9f797613d2 | |||
| b728ff33ec | |||
| 7dc036c6e4 | |||
| 78135aac02 | |||
| 950018673f | |||
| cfdcf05135 | |||
| ace474eb2c | |||
| 285ed8d56e | |||
| 738e25feda | |||
| 6d2d071293 | |||
| 725ce50608 | |||
| 78e884e97e | |||
| cb123cb575 | |||
| 6f3081cb8e | |||
| 231f734fd5 | |||
| fce7325a24 | |||
| 2bec45411f | |||
| 577195b578 | |||
| a402227c4f | |||
| a75b790654 | |||
| f07cbeac52 | |||
| dcfcce7803 | |||
| 659868181d | |||
| d76e6444e0 | |||
| 0dbbf4840c | |||
| 1b6af9fc08 | |||
| 8e1541ea56 | |||
| fd9908833a | |||
| 24f8300b2c | |||
| 8d510f5d90 | |||
| 6457680065 | |||
| 3175911105 | |||
| 00f26bdc75 | |||
| 9db95bb772 | |||
| e1ba96784c | |||
| f67d1d41cb | |||
| bb7367615d | |||
| f8be8fb583 | |||
| e00393936f | |||
| 3c782de64d | |||
| 678dc9ef6b | |||
| 67bb9cd4a5 | |||
| 51cfb9062a | |||
| 1ecf979721 | |||
| 642b0fdc95 | |||
| bc64d6886a | |||
| 81399c59f7 | |||
| 69d10ef429 | |||
| 3c616f4f6f | |||
| 988b61e459 | |||
| 3d4e0b8fc0 | |||
| 564fcfca61 | |||
| 709e1da768 |
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "Zipline Codespace",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspace",
|
||||
"forwardPorts": [3000, 5432],
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/common-utils:2": {},
|
||||
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {},
|
||||
"ghcr.io/devcontainers/features/node:1": {}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"extensions": ["prisma.prisma", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
||||
}
|
||||
},
|
||||
"remoteUser": "zipline",
|
||||
"remoteEnv": {
|
||||
"CORE_DATABASE_URL": "postgres://postgres:postgres@localhost/zip10"
|
||||
},
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
"label": "Zipline",
|
||||
"onAutoForward": "openBrowser"
|
||||
},
|
||||
"5432": {
|
||||
"label": "Postgres"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
image: mcr.microsoft.com/vscode/devcontainers/javascript-node:0-18
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
network_mode: service:db
|
||||
command: sleep infinity
|
||||
user: zipline
|
||||
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:
|
||||
|
||||
+9
-10
@@ -1,7 +1,7 @@
|
||||
# every field in here is optional except, CORE_SECRET and CORE_DATABASE_URL.
|
||||
# if CORE_SECRET is still "changethis" then zipline will exit and tell you to change it.
|
||||
|
||||
# if using s3/swift make sure to comment out the other datasources
|
||||
# if using s3/supabase make sure to comment out the other datasources
|
||||
|
||||
CORE_HTTPS=true
|
||||
CORE_SECRET="changethis"
|
||||
@@ -25,15 +25,14 @@ DATASOURCE_S3_REGION=us-west-2
|
||||
DATASOURCE_S3_FORCE_S3_PATH=false
|
||||
DATASOURCE_S3_USE_SSL=false
|
||||
|
||||
# or you can use swift
|
||||
DATASOURCE_TYPE=swift
|
||||
DATASOURCE_SWIFT_CONTAINER=container
|
||||
DATASOURCE_SWIFT_AUTH_ENDPOINT="https://something/v3"
|
||||
DATASOURCE_SWIFT_USERNAME=username
|
||||
DATASOURCE_SWIFT_PASSWORD=password
|
||||
DATASOURCE_SWIFT_PROJECT_ID=project_id
|
||||
DATASOURCE_SWIFT_DOMAIN_ID=domain_id
|
||||
# or supabase
|
||||
DATASOURCE_TYPE=supabase
|
||||
DATASOURCE_SUPABASE_KEY=xxx
|
||||
# remember: no leading slash
|
||||
DATASOURCE_SUPABASE_URL=https://something.supabase.co
|
||||
DATASOURCE_SUPABASE_BUCKET=zipline
|
||||
|
||||
UPLOADER_DEFAULT_FORMAT=RANDOM
|
||||
UPLOADER_ROUTE=/u
|
||||
UPLOADER_LENGTH=6
|
||||
UPLOADER_ADMIN_LIMIT=104900000
|
||||
@@ -44,4 +43,4 @@ URLS_ROUTE=/go
|
||||
URLS_LENGTH=6
|
||||
|
||||
RATELIMIT_USER = 5
|
||||
RATELIMIT_ADMIN = 3
|
||||
RATELIMIT_ADMIN = 3
|
||||
|
||||
@@ -17,8 +17,9 @@ body:
|
||||
label: Version
|
||||
description: What version of Zipline are you using?
|
||||
options:
|
||||
- upstream
|
||||
- latest
|
||||
- upstream (ghcr.io/diced/zipline:trunk)
|
||||
- latest (ghcr.io/diced/zipline:latest)
|
||||
- other (provide version in additional info)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
@@ -28,14 +29,15 @@ body:
|
||||
multiple: true
|
||||
options:
|
||||
- Firefox
|
||||
- Chrome
|
||||
- Chromium-based (Chrome, Edge, Brave, Opera, mobile chrome/chromium based, etc)
|
||||
- Safari
|
||||
- Microsoft Edge
|
||||
- Firefox Mobile
|
||||
- Safari Mobile
|
||||
- type: textarea
|
||||
id: zipline-logs
|
||||
attributes:
|
||||
label: Zipline Logs
|
||||
description: Please copy and paste any relevant log output.
|
||||
description: Please copy and paste any relevant log output. Not seeing anything interesting? Try adding the `DEBUG=true` environment variable to see more logs, make sure to review the output and remove any sensitive information as it can be VERY verbose at times.
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: browser-logs
|
||||
@@ -43,3 +45,8 @@ body:
|
||||
label: Browser Logs
|
||||
description: Please copy and paste any relevant log output.
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: Additional Info
|
||||
description: Anything else that could be used to narrow down the issue, like your config.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Zipline Discord
|
||||
url: https://discord.gg/EAhCRfGxCF
|
||||
about: Ask for help with anything related to Zipline!
|
||||
- name: Zipline Docs
|
||||
url: https://zipline.diced.tech
|
||||
about: Maybe take a look a the docs?
|
||||
@@ -1,12 +0,0 @@
|
||||
name: Suggestion
|
||||
description: Suggest a feature to be added
|
||||
title: 'Suggestion: '
|
||||
labels: ['suggestion']
|
||||
body:
|
||||
- type: textarea
|
||||
id: suggest
|
||||
attributes:
|
||||
label: Suggestion
|
||||
description: Be as descriptive as possible!
|
||||
placeholder: What do you want in Zipline?
|
||||
value: A suggestion
|
||||
+1
-1
@@ -41,5 +41,5 @@ yarn-error.log*
|
||||
|
||||
# zipline
|
||||
config.toml
|
||||
uploads/
|
||||
uploads*/
|
||||
dist/
|
||||
File diff suppressed because one or more lines are too long
+310
-288
File diff suppressed because one or more lines are too long
+3
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+60
-48
@@ -1,63 +1,75 @@
|
||||
FROM ghcr.io/diced/prisma-binaries:4.5.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
|
||||
# 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/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
|
||||
# Set the entrypoint to the startup script
|
||||
ENTRYPOINT ["tini", "--", "/zipline/docker-entrypoint.sh"]
|
||||
@@ -8,9 +8,9 @@ A ShareX/file upload server that is easy to use, packed with features, and with
|
||||

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

|
||||
[](https://github.com/diced/zipline/pkgs/container/zipline/?tag=trunk)
|
||||
[](https://github.com/diced/zipline/pkgs/container/zipline/?tag=latest)
|
||||

|
||||
[](https://github.com/diced/zipline/pkgs/container/zipline/?tag=trunk)
|
||||
[](https://github.com/diced/zipline/pkgs/container/zipline/?tag=latest)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,7 @@ A ShareX/file upload server that is easy to use, packed with features, and with
|
||||
- Code highlighting
|
||||
- Fully customizable Discord webhook notifications
|
||||
- OAuth2 registration (Discord and GitHub)
|
||||
- Two-Factor authentication with Google Authenticator, Authy, etc (totp services).
|
||||
- User invites
|
||||
- File Chunking (for large files)
|
||||
- File deletion once it reaches a certain amount of views
|
||||
@@ -113,6 +114,17 @@ After navigating to Zipline, click on the top right corner where it says your us
|
||||
|
||||
This section requires [Flameshot](https://www.flameshot.org/), [jq](https://stedolan.github.io/jq/), and [xsel](https://github.com/kfish/xsel).
|
||||
|
||||
<details>
|
||||
<summary>Wayland instructions</summary>
|
||||
|
||||
If using wayland you will need to have [wl-clipboard](https://github.com/bugaevc/wl-clipboard) installed, for the `wl-copy` command.
|
||||
|
||||
If you are not using GNOME/KDE/Qtile/Sway, and are using something like a wlroots-based compositor (ex. [Hyprland](https://github.com/hyprwm/Hyprland/), [River](https://github.com/riverwm/river), etc), you will need to set the `XDG_CURRENT_DESKTOP` environment variable to `sway`, which will just override it for this script. Adding `export XDG_CURRENT_DESKTOP=sway` to the start of the script will work.
|
||||
|
||||
After this, replace the `xsel -ib` with `wl-copy` in the script.
|
||||
|
||||
</details>
|
||||
|
||||
You can either use the script below, or generate one directly from Zipline (just like how you can generate a ShareX config).
|
||||
To upload files using flameshot we will use a script. Replace $TOKEN and $HOST with your own values, you probably know how to do this if you use linux.
|
||||
|
||||
@@ -127,7 +139,7 @@ curl -H "Content-Type: multipart/form-data" -H "authorization: $TOKEN" -F file=@
|
||||
|
||||
## Bug reports
|
||||
|
||||
Create an issue on GitHub, please include the following (if one of them is not applicable to the issue then it's not needed):
|
||||
Create an issue on GitHub and use the template, please include the following (if one of them is not applicable to the issue then it's not needed):
|
||||
|
||||
- The steps to reproduce the bug
|
||||
- Logs of Zipline
|
||||
@@ -137,10 +149,10 @@ 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 a 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)
|
||||
- Brief explanation of the feature in the title (very brief please)
|
||||
- How it would work (Be detailed!)
|
||||
|
||||
## Pull Requests (contributions to the codebase)
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 3.4.8 | :white_check_mark: |
|
||||
| 3.6.x | :white_check_mark: |
|
||||
| < 3 | :x: |
|
||||
| < 2 | :x: |
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
version: '3'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
restart: always
|
||||
image: postgres:15
|
||||
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:
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
version: '3'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
restart: always
|
||||
image: postgres:15
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
@@ -19,9 +19,9 @@ services:
|
||||
image: ghcr.io/diced/zipline
|
||||
ports:
|
||||
- '3000:3000'
|
||||
restart: always
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- CORE_HTTPS=false
|
||||
- CORE_RETURN_HTTPS=false
|
||||
- CORE_SECRET=changethis
|
||||
- CORE_HOST=0.0.0.0
|
||||
- CORE_PORT=3000
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
})();
|
||||
@@ -2,15 +2,6 @@
|
||||
* @type {import('next').NextConfig}
|
||||
**/
|
||||
module.exports = {
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/',
|
||||
destination: '/dashboard',
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
images: {
|
||||
domains: [
|
||||
// For sharex icon in manage user
|
||||
|
||||
+58
-46
@@ -1,86 +1,98 @@
|
||||
{
|
||||
"name": "zipline",
|
||||
"version": "3.6.0",
|
||||
"version": "3.7.0-rc4",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "npm-run-all build:server dev:run",
|
||||
"dev:run": "cross-env REACT_EDITOR=code NODE_ENV=development RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED=false node --enable-source-maps dist/server",
|
||||
"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:up": "docker-compose up",
|
||||
"docker:down": "docker-compose down",
|
||||
"docker:build-dev": "docker-compose --file docker-compose.dev.yml up --build",
|
||||
"scripts:read-config": "npm-run-all build:server && node dist/scripts/read-config",
|
||||
"scripts:import-dir": "npm-run-all build:server && node dist/scripts/import-dir"
|
||||
"docker:up-dev": "docker-compose --file docker-compose.dev.yml up",
|
||||
"docker: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.6.3",
|
||||
"@mantine/dropzone": "^5.6.3",
|
||||
"@mantine/form": "^5.6.3",
|
||||
"@mantine/hooks": "^5.6.3",
|
||||
"@mantine/modals": "^5.6.3",
|
||||
"@mantine/next": "^5.6.3",
|
||||
"@mantine/notifications": "^5.6.3",
|
||||
"@mantine/nprogress": "^5.6.3",
|
||||
"@mantine/prism": "^5.6.3",
|
||||
"@prisma/client": "^4.5.0",
|
||||
"@prisma/internals": "^4.5.0",
|
||||
"@prisma/migrate": "^4.5.0",
|
||||
"@sapphire/shapeshift": "^3.7.0",
|
||||
"@tanstack/react-query": "^4.13.0",
|
||||
"argon2": "^0.30.1",
|
||||
"chart.js": "^3.9.1",
|
||||
"chartjs-plugin-datalabels": "^2.1.0",
|
||||
"color-hash": "^2.0.1",
|
||||
"colorette": "^2.0.19",
|
||||
"@mantine/core": "^5.10.5",
|
||||
"@mantine/dropzone": "^5.10.5",
|
||||
"@mantine/form": "^5.10.5",
|
||||
"@mantine/hooks": "^5.10.5",
|
||||
"@mantine/modals": "^5.10.5",
|
||||
"@mantine/next": "^5.10.5",
|
||||
"@mantine/notifications": "^5.10.5",
|
||||
"@mantine/prism": "^5.10.5",
|
||||
"@prisma/client": "^4.10.1",
|
||||
"@prisma/internals": "^4.10.1",
|
||||
"@prisma/migrate": "^4.10.1",
|
||||
"@sapphire/shapeshift": "^3.8.1",
|
||||
"@tanstack/react-query": "^4.24.10",
|
||||
"argon2": "^0.30.3",
|
||||
"cookie": "^0.5.0",
|
||||
"dayjs": "^1.11.6",
|
||||
"dayjs": "^1.11.7",
|
||||
"dotenv": "^16.0.3",
|
||||
"dotenv-expand": "^9.0.0",
|
||||
"dotenv-expand": "^10.0.0",
|
||||
"exiftool-vendored": "^21.2.0",
|
||||
"fastify": "^4.13.0",
|
||||
"fastify-plugin": "^4.5.0",
|
||||
"fflate": "^0.7.4",
|
||||
"find-my-way": "^7.3.1",
|
||||
"find-my-way": "^7.5.0",
|
||||
"katex": "^0.16.4",
|
||||
"mantine-datatable": "^1.8.6",
|
||||
"minio": "^7.0.32",
|
||||
"ms": "canary",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"next": "^13.0.0",
|
||||
"prisma": "^4.5.0",
|
||||
"next": "^13.2.1",
|
||||
"otplib": "^12.0.1",
|
||||
"prisma": "^4.10.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"qrcode": "^1.5.1",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^4.3.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-feather": "^2.0.10",
|
||||
"react-markdown": "^8.0.5",
|
||||
"recharts": "^2.4.3",
|
||||
"recoil": "^0.7.6",
|
||||
"sharp": "^0.31.1"
|
||||
"remark-gfm": "^3.0.1",
|
||||
"sharp": "^0.31.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie": "^0.5.1",
|
||||
"@types/minio": "^7.0.14",
|
||||
"@types/katex": "^0.16.0",
|
||||
"@types/minio": "^7.0.16",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^18.11.7",
|
||||
"@types/react": "^18.0.24",
|
||||
"@types/sharp": "^0.31.0",
|
||||
"@types/node": "^18.14.2",
|
||||
"@types/qrcode": "^1.5.0",
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"esbuild": "^0.15.12",
|
||||
"eslint": "^8.26.0",
|
||||
"eslint-config-next": "^13.0.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint": "^8.35.0",
|
||||
"eslint-config-next": "^13.2.1",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^2.7.1",
|
||||
"typescript": "^4.8.4"
|
||||
"prettier": "^2.8.4",
|
||||
"tsup": "^6.6.3",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/diced/zipline.git"
|
||||
},
|
||||
"packageManager": "yarn@3.2.4"
|
||||
"packageManager": "yarn@3.3.1"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "totpSecret" TEXT;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "InvisibleImage" DROP CONSTRAINT "InvisibleImage_imageId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "InvisibleUrl" DROP CONSTRAINT "InvisibleUrl_urlId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Invite" DROP CONSTRAINT "Invite_createdById_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Url" DROP CONSTRAINT "Url_userId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Url" ALTER COLUMN "userId" DROP NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvisibleImage" ADD CONSTRAINT "InvisibleImage_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Url" ADD CONSTRAINT "Url_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvisibleUrl" ADD CONSTRAINT "InvisibleUrl_urlId_fkey" FOREIGN KEY ("urlId") REFERENCES "Url"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[provider,oauthId]` on the table `OAuth` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `oauthId` to the `OAuth` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "OAuth" ADD COLUMN "oauthId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OAuth_provider_oauthId_key" ON "OAuth"("provider", "oauthId");
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "File" ADD COLUMN "size" INTEGER NOT NULL DEFAULT 0;
|
||||
+64
-38
@@ -16,80 +16,103 @@ model User {
|
||||
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}")
|
||||
embed Json @default("{}")
|
||||
ratelimit DateTime?
|
||||
totpSecret String?
|
||||
domains String[]
|
||||
oauth OAuth[]
|
||||
images Image[]
|
||||
files File[]
|
||||
urls Url[]
|
||||
Invite Invite[]
|
||||
Folder Folder[]
|
||||
}
|
||||
|
||||
enum ImageFormat {
|
||||
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[]
|
||||
}
|
||||
|
||||
enum FileNameFormat {
|
||||
UUID
|
||||
DATE
|
||||
RANDOM
|
||||
NAME
|
||||
}
|
||||
|
||||
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?
|
||||
format FileNameFormat @default(RANDOM)
|
||||
|
||||
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])
|
||||
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])
|
||||
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])
|
||||
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])
|
||||
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
|
||||
}
|
||||
|
||||
@@ -99,8 +122,11 @@ model OAuth {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
username String
|
||||
oauthId String?
|
||||
token String
|
||||
refresh String?
|
||||
|
||||
@@unique([provider, oauthId])
|
||||
}
|
||||
|
||||
enum OauthProviders {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 279 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 193 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 27 KiB |
@@ -1,4 +1,5 @@
|
||||
import { createStyles, MantineSize, Textarea } from '@mantine/core';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const useStyles = createStyles((theme, { size }: { size: MantineSize }) => ({
|
||||
input: {
|
||||
@@ -11,5 +12,26 @@ const useStyles = createStyles((theme, { size }: { size: MantineSize }) => ({
|
||||
export default function CodeInput({ ...props }) {
|
||||
const { classes } = useStyles({ size: 'md' }, { 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} />;
|
||||
}
|
||||
|
||||
+228
-25
@@ -1,8 +1,23 @@
|
||||
import { Button, Card, Group, LoadingOverlay, Modal, Stack, Text, Title, Tooltip } from '@mantine/core';
|
||||
import {
|
||||
ActionIcon,
|
||||
Card,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { relativeTime } from 'lib/utils/client';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
|
||||
import { useFolders } from 'lib/queries/folders';
|
||||
import { bytesToHuman } from 'lib/utils/bytes';
|
||||
import { relativeTime } from 'lib/utils/client';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CalendarIcon,
|
||||
@@ -10,16 +25,20 @@ import {
|
||||
CopyIcon,
|
||||
CrossIcon,
|
||||
DeleteIcon,
|
||||
DownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
EyeIcon,
|
||||
HardDriveIcon,
|
||||
FileIcon,
|
||||
FolderMinusIcon,
|
||||
FolderPlusIcon,
|
||||
HashIcon,
|
||||
ImageIcon,
|
||||
InfoIcon,
|
||||
StarIcon,
|
||||
EyeIcon,
|
||||
} from './icons';
|
||||
import MutedText from './MutedText';
|
||||
import Type from './Type';
|
||||
import Link from './Link';
|
||||
|
||||
export function FileMeta({ Icon, title, subtitle, ...other }) {
|
||||
return other.tooltip ? (
|
||||
@@ -43,12 +62,21 @@ export function FileMeta({ Icon, title, subtitle, ...other }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function File({ image, updateImages, disableMediaPreview }) {
|
||||
export default function File({
|
||||
image,
|
||||
disableMediaPreview,
|
||||
exifEnabled,
|
||||
refreshImages,
|
||||
reducedActions = false,
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [overrideRender, setOverrideRender] = useState(false);
|
||||
const deleteFile = useFileDelete();
|
||||
const favoriteFile = useFileFavorite();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const folders = useFolders();
|
||||
|
||||
const loading = deleteFile.isLoading || favoriteFile.isLoading;
|
||||
|
||||
const handleDelete = async () => {
|
||||
@@ -111,23 +139,115 @@ export default function File({ image, updateImages, disableMediaPreview }) {
|
||||
);
|
||||
};
|
||||
|
||||
const inFolder = image.folderId;
|
||||
|
||||
const refresh = () => {
|
||||
refreshImages();
|
||||
folders.refetch();
|
||||
};
|
||||
|
||||
const removeFromFolder = async () => {
|
||||
const res = await useFetch('/api/user/folders/' + image.folderId, 'DELETE', {
|
||||
file: Number(image.id),
|
||||
});
|
||||
|
||||
refresh();
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Removed from folder',
|
||||
message: res.name,
|
||||
color: 'green',
|
||||
icon: <FolderMinusIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to remove from folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addToFolder = async (t) => {
|
||||
const res = await useFetch('/api/user/folders/' + t, 'POST', {
|
||||
file: Number(image.id),
|
||||
});
|
||||
|
||||
refresh();
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Added to folder',
|
||||
message: res.name,
|
||||
color: 'green',
|
||||
icon: <FolderPlusIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to add to folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const createFolder = (t) => {
|
||||
useFetch('/api/user/folders', 'POST', {
|
||||
name: t,
|
||||
add: [Number(image.id)],
|
||||
}).then((res) => {
|
||||
refresh();
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Created & added to folder',
|
||||
message: res.name,
|
||||
color: 'green',
|
||||
icon: <FolderPlusIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to create folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
});
|
||||
return { value: t, label: t };
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>{image.file}</Title>} size='xl'>
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>{image.name}</Title>} size='xl'>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Stack>
|
||||
<Type
|
||||
file={image}
|
||||
src={`/r/${image.file}`}
|
||||
alt={image.file}
|
||||
src={`/r/${encodeURI(image.name)}`}
|
||||
alt={image.name}
|
||||
popup
|
||||
sx={{ minHeight: 200 }}
|
||||
style={{ minHeight: 200 }}
|
||||
disableMediaPreview={false}
|
||||
overrideRender={overrideRender}
|
||||
setOverrideRender={setOverrideRender}
|
||||
/>
|
||||
<Stack>
|
||||
<FileMeta Icon={FileIcon} title='Name' subtitle={image.file} />
|
||||
<SimpleGrid
|
||||
my='md'
|
||||
cols={3}
|
||||
breakpoints={[
|
||||
{ maxWidth: 600, cols: 1 },
|
||||
{ maxWidth: 900, cols: 2 },
|
||||
{ maxWidth: 1200, cols: 3 },
|
||||
]}
|
||||
>
|
||||
<FileMeta Icon={FileIcon} title='Name' subtitle={image.name} />
|
||||
<FileMeta Icon={ImageIcon} title='Type' subtitle={image.mimetype} />
|
||||
<FileMeta Icon={HardDriveIcon} title='Size' subtitle={bytesToHuman(image.size || 0)} />
|
||||
<FileMeta Icon={EyeIcon} title='Views' subtitle={image?.views?.toLocaleString()} />
|
||||
{image.maxViews && (
|
||||
<FileMeta
|
||||
@@ -140,28 +260,111 @@ export default function File({ image, updateImages, disableMediaPreview }) {
|
||||
<FileMeta
|
||||
Icon={CalendarIcon}
|
||||
title='Uploaded'
|
||||
subtitle={relativeTime(new Date(image.created_at))}
|
||||
tooltip={new Date(image?.created_at).toLocaleString()}
|
||||
subtitle={relativeTime(new Date(image.createdAt))}
|
||||
tooltip={new Date(image?.createdAt).toLocaleString()}
|
||||
/>
|
||||
{image.expires_at && (
|
||||
{image.expiresAt && !reducedActions && (
|
||||
<FileMeta
|
||||
Icon={ClockIcon}
|
||||
title='Expires'
|
||||
subtitle={relativeTime(new Date(image.expires_at))}
|
||||
tooltip={new Date(image.expires_at).toLocaleString()}
|
||||
subtitle={relativeTime(new Date(image.expiresAt))}
|
||||
tooltip={new Date(image.expiresAt).toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
<FileMeta Icon={HashIcon} title='ID' subtitle={image.id} />
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Group position='right' mt='md'>
|
||||
<Button onClick={handleCopy}>Copy URL</Button>
|
||||
<Button onClick={handleDelete}>Delete</Button>
|
||||
<Button onClick={handleFavorite}>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
|
||||
<Link href={image.url} target='_blank'>
|
||||
<Button rightIcon={<ExternalLinkIcon />}>Open</Button>
|
||||
</Link>
|
||||
<Group position='apart' my='md'>
|
||||
<Group position='left'>
|
||||
{exifEnabled && !reducedActions && (
|
||||
<Tooltip label='View Metadata'>
|
||||
<ActionIcon
|
||||
color='blue'
|
||||
variant='filled'
|
||||
onClick={() => window.open(`/dashboard/metadata/${image.id}`, '_blank')}
|
||||
>
|
||||
<InfoIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{reducedActions ? null : inFolder && !folders.isLoading ? (
|
||||
<Tooltip
|
||||
label={`Remove from folder "${
|
||||
folders.data.find((f) => f.id === image.folderId)?.name ?? ''
|
||||
}"`}
|
||||
>
|
||||
<ActionIcon
|
||||
color='red'
|
||||
variant='filled'
|
||||
onClick={removeFromFolder}
|
||||
loading={folders.isLoading}
|
||||
>
|
||||
<FolderMinusIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label='Add to folder'>
|
||||
<Select
|
||||
onChange={addToFolder}
|
||||
placeholder='Add to folder'
|
||||
data={[
|
||||
...(folders.data ? folders.data : []).map((folder) => ({
|
||||
value: String(folder.id),
|
||||
label: `${folder.id}: ${folder.name}`,
|
||||
})),
|
||||
]}
|
||||
searchable
|
||||
creatable
|
||||
getCreateLabel={(query) => `Create folder "${query}"`}
|
||||
onCreate={createFolder}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
{reducedActions ? null : (
|
||||
<>
|
||||
<Tooltip label='Delete file'>
|
||||
<ActionIcon color='red' variant='filled' onClick={handleDelete}>
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={image.favorite ? 'Unfavorite' : 'Favorite'}>
|
||||
<ActionIcon
|
||||
color={image.favorite ? 'yellow' : 'gray'}
|
||||
variant='filled'
|
||||
onClick={handleFavorite}
|
||||
>
|
||||
<StarIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip label='Open in new tab'>
|
||||
<ActionIcon color='blue' variant='filled' onClick={() => window.open(image.url, '_blank')}>
|
||||
<ExternalLinkIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Copy URL'>
|
||||
<ActionIcon color='blue' variant='filled' onClick={handleCopy}>
|
||||
<CopyIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Download'>
|
||||
<ActionIcon
|
||||
color='blue'
|
||||
variant='filled'
|
||||
onClick={() => window.open(`/r/${encodeURI(image.name)}?download=true`, '_blank')}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Modal>
|
||||
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
|
||||
@@ -183,8 +386,8 @@ export default function File({ image, updateImages, disableMediaPreview }) {
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
src={`/r/${image.file}`}
|
||||
alt={image.file}
|
||||
src={`/r/${encodeURI(image.name)}`}
|
||||
alt={image.name}
|
||||
onClick={() => setOpen(true)}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
|
||||
import { useFolders } from 'lib/queries/folders';
|
||||
import { relativeTime } from 'lib/utils/client';
|
||||
import { useState } from 'react';
|
||||
import { FileMeta } from '.';
|
||||
import {
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
CrossIcon,
|
||||
DeleteIcon,
|
||||
DownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
EyeIcon,
|
||||
FileIcon,
|
||||
FolderMinusIcon,
|
||||
FolderPlusIcon,
|
||||
HashIcon,
|
||||
ImageIcon,
|
||||
InfoIcon,
|
||||
StarIcon,
|
||||
} from '../icons';
|
||||
import Type from '../Type';
|
||||
|
||||
export default function FileModal({
|
||||
open,
|
||||
setOpen,
|
||||
file,
|
||||
loading,
|
||||
refresh,
|
||||
reducedActions = false,
|
||||
exifEnabled,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
file: any;
|
||||
loading: boolean;
|
||||
refresh: () => void;
|
||||
reducedActions?: boolean;
|
||||
exifEnabled?: 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: <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}${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: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
favoriteFile.mutate(
|
||||
{ id: file.id, favorite: !file.favorite },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'The file is now ' + (!file.favorite ? 'favorited' : 'unfavorited'),
|
||||
message: '',
|
||||
icon: <StarIcon />,
|
||||
});
|
||||
},
|
||||
|
||||
onError: (res: any) => {
|
||||
showNotification({
|
||||
title: 'Failed to favorite file',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
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: <FolderMinusIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to remove from folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addToFolder = async (t) => {
|
||||
const res = await useFetch('/api/user/folders/' + t, 'POST', {
|
||||
file: Number(file.id),
|
||||
});
|
||||
|
||||
refresh();
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Added to folder',
|
||||
message: res.name,
|
||||
color: 'green',
|
||||
icon: <FolderPlusIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to add to folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const createFolder = (t) => {
|
||||
useFetch('/api/user/folders', 'POST', {
|
||||
name: t,
|
||||
add: [Number(file.id)],
|
||||
}).then((res) => {
|
||||
refresh();
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Created & added to folder',
|
||||
message: res.name,
|
||||
color: 'green',
|
||||
icon: <FolderPlusIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to create folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
});
|
||||
return { value: t, label: t };
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>{file.name}</Title>} size='xl'>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Stack>
|
||||
<Type
|
||||
file={file}
|
||||
src={`/r/${encodeURI(file.name)}`}
|
||||
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={FileIcon} title='Name' subtitle={file.name} />
|
||||
<FileMeta Icon={ImageIcon} title='Type' subtitle={file.mimetype} />
|
||||
<FileMeta Icon={EyeIcon} title='Views' subtitle={file?.views?.toLocaleString()} />
|
||||
{file.maxViews && (
|
||||
<FileMeta
|
||||
Icon={EyeIcon}
|
||||
title='Max views'
|
||||
subtitle={file?.maxViews?.toLocaleString()}
|
||||
tooltip={`This file will be deleted after being viewed ${file?.maxViews?.toLocaleString()} times.`}
|
||||
/>
|
||||
)}
|
||||
<FileMeta
|
||||
Icon={CalendarIcon}
|
||||
title='Uploaded'
|
||||
subtitle={relativeTime(new Date(file.createdAt))}
|
||||
tooltip={new Date(file?.createdAt).toLocaleString()}
|
||||
/>
|
||||
{file.expiresAt && !reducedActions && (
|
||||
<FileMeta
|
||||
Icon={ClockIcon}
|
||||
title='Expires'
|
||||
subtitle={relativeTime(new Date(file.expiresAt))}
|
||||
tooltip={new Date(file.expiresAt).toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
<FileMeta Icon={HashIcon} 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')}
|
||||
>
|
||||
<InfoIcon />
|
||||
</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}>
|
||||
<FolderMinusIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label='Add to folder'>
|
||||
<Select
|
||||
onChange={addToFolder}
|
||||
placeholder='Add to folder'
|
||||
data={[
|
||||
...(folders.data ? folders.data : []).map((folder) => ({
|
||||
value: String(folder.id),
|
||||
label: `${folder.id}: ${folder.name}`,
|
||||
})),
|
||||
]}
|
||||
searchable
|
||||
creatable
|
||||
getCreateLabel={(query) => `Create folder "${query}"`}
|
||||
onCreate={createFolder}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
{reducedActions ? null : (
|
||||
<>
|
||||
<Tooltip label='Delete file'>
|
||||
<ActionIcon color='red' variant='filled' onClick={handleDelete}>
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={file.favorite ? 'Unfavorite' : 'Favorite'}>
|
||||
<ActionIcon
|
||||
color={file.favorite ? 'yellow' : 'gray'}
|
||||
variant='filled'
|
||||
onClick={handleFavorite}
|
||||
>
|
||||
<StarIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip label='Open in new tab'>
|
||||
<ActionIcon color='blue' variant='filled' onClick={() => window.open(file.url, '_blank')}>
|
||||
<ExternalLinkIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Copy URL'>
|
||||
<ActionIcon color='blue' variant='filled' onClick={handleCopy}>
|
||||
<CopyIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Download'>
|
||||
<ActionIcon
|
||||
color='blue'
|
||||
variant='filled'
|
||||
onClick={() => window.open(`/r/${encodeURI(file.name)}?download=true`, '_blank')}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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,
|
||||
}) {
|
||||
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}
|
||||
/>
|
||||
|
||||
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
|
||||
<Card.Section>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Type
|
||||
file={image}
|
||||
sx={{
|
||||
minHeight: 200,
|
||||
maxHeight: 320,
|
||||
fontSize: 70,
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
style={{
|
||||
minHeight: 200,
|
||||
maxHeight: 320,
|
||||
fontSize: 70,
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
src={`/r/${encodeURI(image.name)}`}
|
||||
alt={image.name}
|
||||
onClick={() => setOpen(true)}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+195
-210
@@ -1,47 +1,48 @@
|
||||
import {
|
||||
AppShell,
|
||||
Badge,
|
||||
Box,
|
||||
Burger,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Header,
|
||||
Image,
|
||||
Input,
|
||||
MediaQuery,
|
||||
Menu,
|
||||
Navbar,
|
||||
NavLink,
|
||||
Paper,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
useMantineTheme,
|
||||
Group,
|
||||
Image,
|
||||
Tooltip,
|
||||
Badge,
|
||||
Menu,
|
||||
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 useFetch from 'hooks/useFetch';
|
||||
import { useVersion } from 'lib/queries/version';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { capitalize } from 'lib/utils/client';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
ActivityIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
CrossIcon,
|
||||
DeleteIcon,
|
||||
DiscordIcon,
|
||||
ExternalLinkIcon,
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
GitHubIcon,
|
||||
GoogleIcon,
|
||||
HomeIcon,
|
||||
LinkIcon,
|
||||
LogoutIcon,
|
||||
@@ -51,68 +52,18 @@ import {
|
||||
TypeIcon,
|
||||
UploadIcon,
|
||||
UserIcon,
|
||||
DiscordIcon,
|
||||
GitHubIcon,
|
||||
GoogleIcon,
|
||||
} from './icons';
|
||||
import { friendlyThemeName, themes } from './Theming';
|
||||
|
||||
function MenuItemLink(props) {
|
||||
return (
|
||||
<Link href={props.href} passHref legacyBehavior>
|
||||
<MenuItem {...props} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
export type NavbarItems = {
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
link?: string;
|
||||
children?: NavbarItems[];
|
||||
if?: (user: any, props: any) => boolean;
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const items = [
|
||||
const items: NavbarItems[] = [
|
||||
{
|
||||
icon: <HomeIcon size={18} />,
|
||||
text: 'Home',
|
||||
@@ -123,6 +74,11 @@ const items = [
|
||||
text: 'Files',
|
||||
link: '/dashboard/files',
|
||||
},
|
||||
{
|
||||
icon: <FolderIcon size={18} />,
|
||||
text: 'Folders',
|
||||
link: '/dashboard/folders',
|
||||
},
|
||||
{
|
||||
icon: <ActivityIcon size={18} />,
|
||||
text: 'Stats',
|
||||
@@ -136,27 +92,37 @@ const items = [
|
||||
{
|
||||
icon: <UploadIcon size={18} />,
|
||||
text: 'Upload',
|
||||
link: '/dashboard/upload',
|
||||
children: [
|
||||
{
|
||||
icon: <UploadIcon size={18} />,
|
||||
text: 'File',
|
||||
link: '/dashboard/upload/file',
|
||||
},
|
||||
{
|
||||
icon: <TypeIcon size={18} />,
|
||||
text: 'Text',
|
||||
link: '/dashboard/upload/text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <TypeIcon size={18} />,
|
||||
text: 'Upload Text',
|
||||
link: '/dashboard/text',
|
||||
},
|
||||
];
|
||||
|
||||
const admin_items = [
|
||||
{
|
||||
icon: <UserIcon size={18} />,
|
||||
text: 'Users',
|
||||
link: '/dashboard/users',
|
||||
if: (props) => true,
|
||||
},
|
||||
{
|
||||
icon: <TagIcon size={18} />,
|
||||
text: 'Invites',
|
||||
link: '/dashboard/invites',
|
||||
if: (props) => props.invites,
|
||||
text: 'Administration',
|
||||
if: (user, _) => user.administrator as boolean,
|
||||
children: [
|
||||
{
|
||||
icon: <UserIcon size={18} />,
|
||||
text: 'Users',
|
||||
link: '/dashboard/users',
|
||||
if: () => true,
|
||||
},
|
||||
{
|
||||
icon: <TagIcon size={18} />,
|
||||
text: 'Invites',
|
||||
link: '/dashboard/invites',
|
||||
if: (_, props) => props.invites,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -181,7 +147,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();
|
||||
@@ -251,13 +216,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: <CheckIcon />,
|
||||
});
|
||||
|
||||
modals.closeAll();
|
||||
},
|
||||
@@ -270,39 +251,42 @@ export default function Layout({ children, props }) {
|
||||
navbar={
|
||||
<Navbar pt='sm' hiddenBreakpoint='sm' hidden={!opened} width={{ sm: 200, lg: 230 }}>
|
||||
<Navbar.Section grow component={ScrollArea}>
|
||||
{items.map(({ icon, text, link }) => (
|
||||
<Link href={link} key={text} passHref legacyBehavior>
|
||||
<NavLink
|
||||
component='a'
|
||||
label={text}
|
||||
icon={icon}
|
||||
active={router.pathname === link}
|
||||
variant='light'
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
{user.administrator && (
|
||||
<NavLink
|
||||
label='Administration'
|
||||
icon={<SettingsIcon />}
|
||||
childrenOffset={28}
|
||||
defaultOpened={admin_items.map((x) => x.link).includes(router.pathname)}
|
||||
>
|
||||
{admin_items
|
||||
.filter((x) => x.if(props))
|
||||
.map(({ icon, text, link }) => (
|
||||
<Link href={link} key={text} passHref legacyBehavior>
|
||||
<NavLink
|
||||
component='a'
|
||||
label={text}
|
||||
icon={icon}
|
||||
active={router.pathname === link}
|
||||
variant='light'
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</NavLink>
|
||||
)}
|
||||
{items
|
||||
.filter((x) => (x.if ? x.if(user, props) : true))
|
||||
.map(({ icon, text, link, children }) =>
|
||||
children ? (
|
||||
<NavLink
|
||||
key={text}
|
||||
label={text}
|
||||
icon={icon}
|
||||
defaultOpened={children.map((x) => x.link).includes(router.pathname)}
|
||||
>
|
||||
{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>
|
||||
) : (
|
||||
<Link href={link} key={text} passHref legacyBehavior>
|
||||
<NavLink
|
||||
component='a'
|
||||
label={text}
|
||||
icon={icon}
|
||||
active={router.pathname === link}
|
||||
variant='light'
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</Navbar.Section>
|
||||
<Navbar.Section>
|
||||
{external_links.length
|
||||
@@ -323,9 +307,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
|
||||
@@ -333,9 +319,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>
|
||||
@@ -355,11 +341,19 @@ 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': {
|
||||
@@ -372,82 +366,73 @@ export default function Layout({ children, props }) {
|
||||
>
|
||||
{user.username}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
|
||||
<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.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>
|
||||
{user.username} ({user.id}){' '}
|
||||
{user.administrator && user.username !== 'administrator' ? '(Administrator)' : ''}
|
||||
</Menu.Label>
|
||||
<Menu.Item component={Link} icon={<SettingsIcon />} href='/dashboard/manage'>
|
||||
Manage Account
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
icon={<CopyIcon />}
|
||||
onClick={() => {
|
||||
openCopyToken();
|
||||
}}
|
||||
>
|
||||
Copy Token
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
icon={<DeleteIcon />}
|
||||
onClick={() => {
|
||||
openResetToken();
|
||||
}}
|
||||
color='red'
|
||||
>
|
||||
Reset Token
|
||||
</Menu.Item>
|
||||
<Menu.Item component={Link} icon={<LogoutIcon />} href='/auth/logout' color='red'>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<>
|
||||
{oauth_providers
|
||||
.filter((x) =>
|
||||
user.oauth
|
||||
?.map(({ provider }) => provider.toLowerCase())
|
||||
.includes(x.name.toLowerCase())
|
||||
).length ? (
|
||||
<Menu.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={<PencilIcon />}>
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// https://mantine.dev/core/password-input/
|
||||
|
||||
import { Box, PasswordInput, Popover, Progress, Text } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import { PasswordInput, Progress, Text, Popover, Box } from '@mantine/core';
|
||||
import { CheckIcon, CrossIcon } from './icons';
|
||||
|
||||
function PasswordRequirement({ meets, label }: { meets: boolean; label: string }) {
|
||||
|
||||
@@ -12,12 +12,12 @@ import matcha_dark_azul from 'lib/themes/matcha_dark_azul';
|
||||
import nord from 'lib/themes/nord';
|
||||
import qogir_dark from 'lib/themes/qogir_dark';
|
||||
|
||||
import { MantineProvider, MantineThemeOverride } from '@mantine/core';
|
||||
import { createEmotionCache, MantineProvider, MantineThemeOverride } from '@mantine/core';
|
||||
import { useColorScheme } from '@mantine/hooks';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import { NotificationsProvider } from '@mantine/notifications';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
export const themes = {
|
||||
system: (colorScheme: 'dark' | 'light') => (colorScheme === 'dark' ? dark_blue : light_blue),
|
||||
@@ -47,6 +47,8 @@ export const friendlyThemeName = {
|
||||
qogir_dark: 'Qogir Dark',
|
||||
};
|
||||
|
||||
const cache = createEmotionCache({ key: 'zipline' });
|
||||
|
||||
export default function ZiplineTheming({ Component, pageProps, ...props }) {
|
||||
const user = useRecoilValue(userSelector);
|
||||
const colorScheme = useColorScheme();
|
||||
@@ -65,8 +67,14 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
emotionCache={cache}
|
||||
theme={{
|
||||
...theme,
|
||||
fontFamily: 'Ubuntu, sans-serif',
|
||||
fontFamilyMonospace: 'Ubuntu Mono, monospace',
|
||||
headings: {
|
||||
fontFamily: 'Ubuntu, sans-serif',
|
||||
},
|
||||
components: {
|
||||
AppShell: {
|
||||
styles: (t) => ({
|
||||
@@ -87,11 +95,13 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
overlayColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
|
||||
exitTransitionDuration: 100,
|
||||
},
|
||||
},
|
||||
Popover: {
|
||||
defaultProps: {
|
||||
transition: 'pop',
|
||||
shadow: 'lg',
|
||||
},
|
||||
},
|
||||
LoadingOverlay: {
|
||||
@@ -123,7 +133,7 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
|
||||
}}
|
||||
>
|
||||
<ModalsProvider>
|
||||
<NotificationsProvider>
|
||||
<NotificationsProvider position='top-center' style={{ marginTop: -10 }}>
|
||||
{props.children ? props.children : <Component {...pageProps} />}
|
||||
</NotificationsProvider>
|
||||
</ModalsProvider>
|
||||
|
||||
+120
-32
@@ -1,61 +1,144 @@
|
||||
import { Group, Image, Text } from '@mantine/core';
|
||||
import { Prism } from '@mantine/prism';
|
||||
import exts from 'lib/exts';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
LoadingOverlay,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AudioIcon, FileIcon, PlayIcon } from './icons';
|
||||
import { AudioIcon, FileIcon, ImageIcon, PlayIcon } from './icons';
|
||||
import KaTeX from './render/KaTeX';
|
||||
import Markdown from './render/Markdown';
|
||||
import PrismCode from './render/PrismCode';
|
||||
|
||||
function PlaceholderContent({ text, Icon }) {
|
||||
return (
|
||||
<Group sx={(t) => ({ color: t.colors.dark[2] })}>
|
||||
<Icon size={48} />
|
||||
<Text size='md'>{text}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function Placeholder({ text, Icon, ...props }) {
|
||||
if (props.disableResolve) props.src = null;
|
||||
if (props.onClick)
|
||||
return (
|
||||
<UnstyledButton sx={{ height: 200 }} {...props}>
|
||||
<Center sx={{ height: 200 }}>
|
||||
<PlaceholderContent text={text} Icon={Icon} />
|
||||
</Center>
|
||||
</UnstyledButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<Image
|
||||
height={200}
|
||||
withPlaceholder
|
||||
placeholder={
|
||||
<Group>
|
||||
<Icon size={48} />
|
||||
<Text size='md'>{text}</Text>
|
||||
</Group>
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
<Box sx={{ height: 200 }} {...props}>
|
||||
<Center sx={{ height: 200 }}>
|
||||
<PlaceholderContent text={text} Icon={Icon} />
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 = file.name.endsWith('.md');
|
||||
const shouldRenderTex = file.name.endsWith('.tex');
|
||||
const shouldRenderCode: boolean = Object.keys(exts).includes(file.name.split('.').pop());
|
||||
|
||||
if (type === 'text') {
|
||||
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);
|
||||
})();
|
||||
}, []);
|
||||
}
|
||||
|
||||
if (media && disableMediaPreview) {
|
||||
const renderAlert = () => {
|
||||
return (
|
||||
<Placeholder Icon={FileIcon} text={`Click to view file (${name})`} disableResolve={true} {...props} />
|
||||
<Alert color='blue' variant='outline' sx={{ width: '100%' }}>
|
||||
You are{props.overrideRender ? ' not ' : ' '}viewing a rendered version of the file
|
||||
<Button
|
||||
mx='md'
|
||||
onClick={() => props.setOverrideRender(!props.overrideRender)}
|
||||
compact
|
||||
variant='light'
|
||||
>
|
||||
View {props.overrideRender ? 'rendered' : 'raw'}
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
if ((shouldRenderMarkdown || shouldRenderTex || shouldRenderCode) && !props.overrideRender && popup)
|
||||
return (
|
||||
<>
|
||||
{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 (${file.name})`} {...props} />;
|
||||
}
|
||||
|
||||
if (file.password) {
|
||||
return (
|
||||
<Placeholder
|
||||
Icon={FileIcon}
|
||||
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} />,
|
||||
image: <Image {...props} />,
|
||||
audio: <audio autoPlay controls {...props} style={{ width: '100%' }} />,
|
||||
video: <video width='100%' autoPlay muted controls {...props} />,
|
||||
image: (
|
||||
<Image
|
||||
placeholder={<PlaceholderContent Icon={FileIcon} text={'Image failed to load...'} />}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
audio: <audio autoPlay muted controls {...props} style={{ width: '100%' }} />,
|
||||
text: (
|
||||
<Prism withLineNumbers language={name.split('.').pop()} {...props} style={{}} sx={{}}>
|
||||
{text}
|
||||
</Prism>
|
||||
<>
|
||||
{loading ? (
|
||||
<LoadingOverlay visible={loading} />
|
||||
) : (
|
||||
<>
|
||||
{(shouldRenderMarkdown || shouldRenderTex) && renderAlert()}
|
||||
<PrismCode code={text} ext={file.name.split('.').pop()} {...props} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}[type]
|
||||
) : (
|
||||
@@ -63,12 +146,17 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
|
||||
)
|
||||
) : media ? (
|
||||
{
|
||||
video: <Placeholder Icon={PlayIcon} text={`Click to view video (${name})`} {...props} />,
|
||||
image: <Image {...props} />,
|
||||
audio: <Placeholder Icon={AudioIcon} text={`Click to view audio (${name})`} {...props} />,
|
||||
text: <Placeholder Icon={FileIcon} text={`Click to view text file (${name})`} {...props} />,
|
||||
video: <Placeholder Icon={PlayIcon} text={`Click to view video (${file.name})`} {...props} />,
|
||||
image: (
|
||||
<Image
|
||||
placeholder={<PlaceholderContent Icon={ImageIcon} text={'Image failed to load...'} />}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
audio: <Placeholder Icon={AudioIcon} text={`Click to view audio (${file.name})`} {...props} />,
|
||||
text: <Placeholder Icon={FileIcon} text={`Click to view text file (${file.name})`} {...props} />,
|
||||
}[type]
|
||||
) : (
|
||||
<Placeholder Icon={FileIcon} text={`Click to view file (${name})`} {...props} />
|
||||
<Placeholder Icon={FileIcon} text={`Click to view file (${file.name})`} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Box, Group, SimpleGrid, Text, useMantineTheme } from '@mantine/core';
|
||||
import { Dropzone as MantineDropzone } from '@mantine/dropzone';
|
||||
import { Group, Text, useMantineTheme } from '@mantine/core';
|
||||
import { ImageIcon } from 'components/icons';
|
||||
|
||||
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 onDrop={onDrop} styles={{ inner: { pointerEvents: 'none' } }}>
|
||||
<Group position='center' spacing='xl' style={{ minHeight: 440 }}>
|
||||
<ImageIcon 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,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Table, Tooltip, Badge, HoverCard, Text, useMantineTheme, Group } from '@mantine/core';
|
||||
import { ActionIcon, Badge, Box, Card, Group, HoverCard, Table, useMantineTheme } from '@mantine/core';
|
||||
import Type from 'components/Type';
|
||||
import { X } from 'react-feather';
|
||||
|
||||
export function FilePreview({ file }: { file: File }) {
|
||||
return (
|
||||
@@ -17,15 +17,36 @@ 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>
|
||||
{/* <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>
|
||||
{/* x button that will remove file */}
|
||||
<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'>
|
||||
<X />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
|
||||
<Group grow>
|
||||
<FilePreview file={file} />
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Database } from 'react-feather';
|
||||
|
||||
export default function DatabaseIcon({ ...props }) {
|
||||
return <Database size={15} {...props} />;
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
// 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'>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Folder } from 'react-feather';
|
||||
|
||||
export default function FolderIcon({ ...props }) {
|
||||
return <Folder size={15} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { FolderMinus } from 'react-feather';
|
||||
|
||||
export default function FolderMinusIcon({ ...props }) {
|
||||
return <FolderMinus size={15} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { FolderPlus } from 'react-feather';
|
||||
|
||||
export default function FolderPlusIcon({ ...props }) {
|
||||
return <FolderPlus size={15} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Globe } from 'react-feather';
|
||||
|
||||
export default function GlobeIcon({ ...props }) {
|
||||
return <Globe size={15} {...props} />;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function GoogleIcon({ ...props }) {
|
||||
export default function GoogleIcon({ colorScheme, ...props }) {
|
||||
return (
|
||||
<Image
|
||||
alt='google'
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { HardDrive } from 'react-feather';
|
||||
|
||||
export default function HardDriveIcon({ ...props }) {
|
||||
return <HardDrive size={15} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Info } from 'react-feather';
|
||||
|
||||
export default function InfoIcon({ ...props }) {
|
||||
return <Info size={15} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Key } from 'react-feather';
|
||||
|
||||
export default function KeyIcon({ ...props }) {
|
||||
return <Key size={15} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Lock } from 'react-feather';
|
||||
|
||||
export default function LockIcon({ ...props }) {
|
||||
return <Lock size={15} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Unlock } from 'react-feather';
|
||||
|
||||
export default function UnlockIcon({ ...props }) {
|
||||
return <Unlock size={15} {...props} />;
|
||||
}
|
||||
@@ -32,6 +32,16 @@ import DiscordIcon from './DiscordIcon';
|
||||
import GoogleIcon from './GoogleIcon';
|
||||
import EyeIcon from './EyeIcon';
|
||||
import RefreshIcon from './RefreshIcon';
|
||||
import KeyIcon from './KeyIcon';
|
||||
import DatabaseIcon from './DatabaseIcon';
|
||||
import InfoIcon from './InfoIcon';
|
||||
import FolderIcon from './FolderIcon';
|
||||
import FolderMinusIcon from './FolderMinusIcon';
|
||||
import FolderPlusIcon from './FolderPlusIcon';
|
||||
import GlobeIcon from './GlobeIcon';
|
||||
import LockIcon from './LockIcon';
|
||||
import UnlockIcon from './UnlockIcon';
|
||||
import HardDriveIcon from './HardDriveIcon';
|
||||
|
||||
export {
|
||||
ActivityIcon,
|
||||
@@ -68,4 +78,14 @@ export {
|
||||
GoogleIcon,
|
||||
EyeIcon,
|
||||
RefreshIcon,
|
||||
KeyIcon,
|
||||
DatabaseIcon,
|
||||
InfoIcon,
|
||||
FolderIcon,
|
||||
FolderMinusIcon,
|
||||
FolderPlusIcon,
|
||||
GlobeIcon,
|
||||
LockIcon,
|
||||
UnlockIcon,
|
||||
HardDriveIcon,
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Box, Card as MantineCard, Center, Group, SimpleGrid, Skeleton, Title } from '@mantine/core';
|
||||
import { Card as MantineCard, Center, Group, SimpleGrid, Skeleton, Title } from '@mantine/core';
|
||||
import { randomId } from '@mantine/hooks';
|
||||
import File from 'components/File';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { invalidateFiles, useRecent } from 'lib/queries/files';
|
||||
import { useRecent } from 'lib/queries/files';
|
||||
import { UploadCloud } from 'react-feather';
|
||||
|
||||
export default function RecentFiles({ disableMediaPreview }) {
|
||||
export default function RecentFiles({ disableMediaPreview, exifEnabled }) {
|
||||
const recent = useRecent('media');
|
||||
|
||||
return (
|
||||
@@ -22,8 +22,9 @@ export default function RecentFiles({ disableMediaPreview }) {
|
||||
<File
|
||||
key={randomId()}
|
||||
image={image}
|
||||
updateImages={invalidateFiles}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
exifEnabled={exifEnabled}
|
||||
refreshImages={recent.refetch}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { SimpleGrid } from '@mantine/core';
|
||||
import { FileIcon } from 'components/icons';
|
||||
import StatCard from 'components/StatCard';
|
||||
import { percentChange } from 'lib/utils/client';
|
||||
import { useStats } from 'lib/queries/stats';
|
||||
import { Database, Eye, Users } from 'react-feather';
|
||||
import { percentChange } from 'lib/utils/client';
|
||||
import { EyeIcon, DatabaseIcon, UserIcon, FileIcon } from 'components/icons';
|
||||
|
||||
export function StatCards() {
|
||||
const stats = useStats();
|
||||
@@ -21,7 +20,7 @@ export function StatCards() {
|
||||
>
|
||||
<StatCard
|
||||
stat={{
|
||||
title: 'UPLOADED FILES',
|
||||
title: 'FILES',
|
||||
value: stats.isSuccess ? latest.data.count.toLocaleString() : '...',
|
||||
desc: 'files have been uploaded',
|
||||
icon: <FileIcon />,
|
||||
@@ -34,8 +33,8 @@ export function StatCards() {
|
||||
stat={{
|
||||
title: 'STORAGE',
|
||||
value: stats.isSuccess ? latest.data.size : '...',
|
||||
desc: 'of storage used',
|
||||
icon: <Database size={15} />,
|
||||
desc: 'used',
|
||||
icon: <DatabaseIcon />,
|
||||
diff:
|
||||
stats.isSuccess && before?.data
|
||||
? percentChange(before.data.size_num, latest.data.size_num)
|
||||
@@ -47,8 +46,8 @@ export function StatCards() {
|
||||
stat={{
|
||||
title: 'VIEWS',
|
||||
value: stats.isSuccess ? latest.data.views_count.toLocaleString() : '...',
|
||||
desc: 'total page views',
|
||||
icon: <Eye size={15} />,
|
||||
desc: 'total file views',
|
||||
icon: <EyeIcon />,
|
||||
diff:
|
||||
stats.isSuccess && before?.data
|
||||
? percentChange(before.data.views_count, latest.data.views_count)
|
||||
@@ -60,8 +59,8 @@ export function StatCards() {
|
||||
stat={{
|
||||
title: 'USERS',
|
||||
value: stats.isSuccess ? latest.data.count_users.toLocaleString() : '...',
|
||||
desc: 'total registered users',
|
||||
icon: <Users size={15} />,
|
||||
desc: 'users',
|
||||
icon: <UserIcon />,
|
||||
}}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -1,48 +1,94 @@
|
||||
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 FileModal from 'components/File/FileModal';
|
||||
import { CopyIcon, CrossIcon, DeleteIcon, EnterIcon, FileIcon } from 'components/icons';
|
||||
import Link from 'components/Link';
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { 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 { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import RecentFiles from './RecentFiles';
|
||||
import { StatCards } from './StatCards';
|
||||
|
||||
export default function Dashboard({ disableMediaPreview }) {
|
||||
export default function Dashboard({ disableMediaPreview, exifEnabled }) {
|
||||
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);
|
||||
|
||||
// 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: 'Image Deleted',
|
||||
message: '',
|
||||
title: 'File Deleted',
|
||||
message: `${file.name}`,
|
||||
color: 'green',
|
||||
icon: <DeleteIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to delete image',
|
||||
title: 'Failed to Delete File',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
@@ -50,102 +96,144 @@ export default function Dashboard({ disableMediaPreview }) {
|
||||
}
|
||||
};
|
||||
|
||||
const copyImage = async ({ original }) => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${original.url}`);
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
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: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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} />
|
||||
<RecentFiles disableMediaPreview={disableMediaPreview} exifEnabled={exifEnabled} />
|
||||
|
||||
<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={<></>}
|
||||
|
||||
<DataTable
|
||||
withBorder
|
||||
borderRadius='md'
|
||||
highlightOnHover
|
||||
verticalSpacing='sm'
|
||||
columns={[
|
||||
{ accessor: 'name', sortable: true },
|
||||
{ accessor: 'mimetype', sortable: true },
|
||||
{
|
||||
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'
|
||||
>
|
||||
<FileIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Open file in new tab'>
|
||||
<ActionIcon onClick={() => viewFile(file)} color='blue'>
|
||||
<EnterIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<ActionIcon onClick={() => copyFile(file)} color='green'>
|
||||
<CopyIcon />
|
||||
</ActionIcon>
|
||||
<ActionIcon onClick={() => deleteFile(file)} color='red'>
|
||||
<DeleteIcon />
|
||||
</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: <EnterIcon />,
|
||||
title: `View ${file.name}`,
|
||||
onClick: () => viewFile(file),
|
||||
},
|
||||
{
|
||||
key: 'copy',
|
||||
icon: <CopyIcon />,
|
||||
title: `Copy ${file.name}`,
|
||||
onClick: () => copyFile(file),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
icon: <DeleteIcon />,
|
||||
title: `Delete ${file.name}`,
|
||||
onClick: () => deleteFile(file),
|
||||
},
|
||||
],
|
||||
}}
|
||||
onCellClick={({ record: file }) => {
|
||||
setSelectedFile(file);
|
||||
setOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,47 @@
|
||||
import { Box, Center, Checkbox, Group, Pagination, SimpleGrid, Skeleton, Text, Title } from '@mantine/core';
|
||||
import { Box, Button, Center, Checkbox, Group, Pagination, SimpleGrid, Skeleton, Title } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
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';
|
||||
import { useState } from 'react';
|
||||
import { showNonMediaSelector } from 'lib/recoil/settings';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
export default function FilePagation({ disableMediaPreview }) {
|
||||
const [checked, setChecked] = useState(false);
|
||||
export default function FilePagation({ disableMediaPreview, exifEnabled, queryPage }) {
|
||||
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 pages = usePaginatedFiles(!checked ? { filter: 'media' } : {});
|
||||
const [page, setPage] = useState(1);
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
router.replace(
|
||||
{
|
||||
query: {
|
||||
...router.query,
|
||||
page: page,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
|
||||
const { count } = await useFetch(`/api/user/paged?count=true${!checked ? '&filter=media' : ''}`);
|
||||
setNumPages(count);
|
||||
})();
|
||||
}, [page]);
|
||||
|
||||
const pages = usePaginatedFiles(page, !checked ? 'media' : null);
|
||||
|
||||
if (pages.isSuccess && pages.data.length === 0) {
|
||||
return (
|
||||
<Center>
|
||||
<Center sx={{ flexDirection: 'column' }}>
|
||||
<Group>
|
||||
<div>
|
||||
<FileIcon size={48} />
|
||||
@@ -23,6 +51,14 @@ export default function FilePagation({ disableMediaPreview }) {
|
||||
<MutedText size='md'>Upload some files and they will show up here.</MutedText>
|
||||
</div>
|
||||
</Group>
|
||||
<Box my='sm' hidden={checked}>
|
||||
<MutedText size='md'>
|
||||
There might be some non-media files, would you like to show them?
|
||||
<Button mx='sm' compact type='button' onClick={() => setChecked(true)}>
|
||||
Show
|
||||
</Button>
|
||||
</MutedText>
|
||||
</Box>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -32,12 +68,13 @@ export default function FilePagation({ disableMediaPreview }) {
|
||||
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
|
||||
{pages.isSuccess
|
||||
? pages.data.length
|
||||
? pages.data[page - 1 ?? 0].map((image) => (
|
||||
? pages.data.map((image) => (
|
||||
<div key={image.id}>
|
||||
<File
|
||||
image={image}
|
||||
updateImages={() => pages.refetch()}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
exifEnabled={exifEnabled}
|
||||
refreshImages={pages.refetch}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
@@ -58,13 +95,15 @@ export default function FilePagation({ disableMediaPreview }) {
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<div></div>
|
||||
<Pagination total={pages.data?.length ?? 0} page={page} onChange={setPage} />
|
||||
<Checkbox
|
||||
label='Show non-media files'
|
||||
checked={checked}
|
||||
onChange={(event) => setChecked(event.currentTarget.checked)}
|
||||
/>
|
||||
{!isMobile && <div></div>}
|
||||
<Pagination total={numPages} page={page} onChange={setPage} withEdges />
|
||||
{!isMobile && (
|
||||
<Checkbox
|
||||
label='Show non-media files'
|
||||
checked={checked}
|
||||
onChange={(event) => setChecked(event.currentTarget.checked)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Title } from '@mantine/core';
|
||||
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 { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import FilePagation from './FilePagation';
|
||||
|
||||
export default function Files({ disableMediaPreview }) {
|
||||
const pages = usePaginatedFiles({ filter: 'media' });
|
||||
const favoritePages = usePaginatedFiles({ favorite: 'media' });
|
||||
export default function Files({ disableMediaPreview, exifEnabled, queryPage }) {
|
||||
const [favoritePage, setFavoritePage] = useState(1);
|
||||
const [favoriteNumPages, setFavoriteNumPages] = useState(0);
|
||||
const favoritePages = usePaginatedFiles(favoritePage, 'media', true);
|
||||
|
||||
const updatePages = async (favorite) => {
|
||||
pages.refetch();
|
||||
|
||||
if (favorite) {
|
||||
favoritePages.refetch();
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { count } = await useFetch('/api/user/paged?count=true&filter=media&favorite=true');
|
||||
setFavoriteNumPages(count);
|
||||
})();
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group mb='md'>
|
||||
<Title>Files</Title>
|
||||
<Link href='/dashboard/upload' passHref legacyBehavior>
|
||||
<Link href='/dashboard/upload/file' passHref legacyBehavior>
|
||||
<ActionIcon component='a' variant='filled' color='primary'>
|
||||
<PlusIcon />
|
||||
</ActionIcon>
|
||||
@@ -43,12 +43,13 @@ export default function Files({ disableMediaPreview }) {
|
||||
<Accordion.Panel>
|
||||
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
|
||||
{favoritePages.isSuccess && favoritePages.data.length
|
||||
? favoritePages.data[favoritePage - 1 ?? 0].map((image) => (
|
||||
? favoritePages.data.map((image) => (
|
||||
<div key={image.id}>
|
||||
<File
|
||||
image={image}
|
||||
updateImages={() => updatePages(true)}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
exifEnabled={exifEnabled}
|
||||
refreshImages={favoritePages.refetch}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
@@ -63,18 +64,18 @@ export default function Files({ disableMediaPreview }) {
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Pagination
|
||||
total={favoritePages.data.length}
|
||||
page={favoritePage}
|
||||
onChange={setFavoritePage}
|
||||
/>
|
||||
<Pagination total={favoriteNumPages} page={favoritePage} onChange={setFavoritePage} />
|
||||
</Box>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
) : null}
|
||||
|
||||
<FilePagation disableMediaPreview={disableMediaPreview} />
|
||||
<FilePagation
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
exifEnabled={exifEnabled}
|
||||
queryPage={queryPage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Button, Group, Modal, TextInput, Title } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { CrossIcon, FolderIcon } from 'components/icons';
|
||||
import 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: <CrossIcon />,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Created folder ' + res.name,
|
||||
message: createWithFile ? 'Added file to folder' : undefined,
|
||||
icon: <FolderIcon />,
|
||||
color: 'green',
|
||||
});
|
||||
|
||||
if (createWithFile) {
|
||||
router.push('/dashboard/folders');
|
||||
}
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
updateFolders();
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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 }) {
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
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 { DeleteIcon, FileIcon, PlusIcon, LockIcon, UnlockIcon, LinkIcon, CopyIcon } from 'components/icons';
|
||||
import Link from 'components/Link';
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useFolders } from 'lib/queries/folders';
|
||||
import { relativeTime } from 'lib/utils/client';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import CreateFolderModal from './CreateFolderModal';
|
||||
import ViewFolderFilesModal from './ViewFolderFilesModal';
|
||||
|
||||
export default function Folders({ disableMediaPreview, exifEnabled }) {
|
||||
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();
|
||||
|
||||
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: <DeleteIcon />,
|
||||
});
|
||||
folders.refetch();
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to delete folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <DeleteIcon />,
|
||||
});
|
||||
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: <UnlockIcon />,
|
||||
});
|
||||
folders.refetch();
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to make folder public/private',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <UnlockIcon />,
|
||||
});
|
||||
folders.refetch();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateFolderModal
|
||||
open={createOpen}
|
||||
setOpen={setCreateOpen}
|
||||
createWithFile={createWithFile}
|
||||
updateFolders={folders.refetch}
|
||||
/>
|
||||
<ViewFolderFilesModal
|
||||
open={viewOpen}
|
||||
setOpen={setViewOpen}
|
||||
folderId={activeFolderId}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
exifEnabled={exifEnabled}
|
||||
/>
|
||||
|
||||
<Group mb='md'>
|
||||
<Title>Folders</Title>
|
||||
<ActionIcon onClick={() => setCreateOpen(!createOpen)} component='a' variant='filled' color='primary'>
|
||||
<PlusIcon />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
|
||||
{folders.isSuccess
|
||||
? folders.data.length
|
||||
? folders.data.map((folder) => (
|
||||
<Card key={folder.id}>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Avatar size='lg' color='primary'>
|
||||
{folder.id}
|
||||
</Avatar>
|
||||
<Stack spacing={0}>
|
||||
<Title>{folder.name}</Title>
|
||||
<MutedText size='sm'>ID: {folder.id}</MutedText>
|
||||
<MutedText size='sm'>Public: {folder.public ? 'Yes' : 'No'}</MutedText>
|
||||
<Tooltip label={new Date(folder.createdAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>
|
||||
Created {relativeTime(new Date(folder.createdAt))}
|
||||
</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip label={new Date(folder.updatedAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>
|
||||
Last updated {relativeTime(new Date(folder.updatedAt))}
|
||||
</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group>
|
||||
<Tooltip label={folder.public ? 'Make folder private' : 'Make folder public'}>
|
||||
<ActionIcon
|
||||
aria-label={folder.public ? 'make private' : 'make public'}
|
||||
onClick={() => makePublic(folder)}
|
||||
>
|
||||
{folder.public ? <LockIcon /> : <UnlockIcon />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
aria-label='view files'
|
||||
onClick={() => {
|
||||
setViewOpen(!viewOpen);
|
||||
setActiveFolderId(folder.id);
|
||||
}}
|
||||
>
|
||||
<FileIcon />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
aria-label='copy link'
|
||||
onClick={() => {
|
||||
clipboard.copy(`${window.location.origin}/folder/${folder.id}`);
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: 'Zipline is unable to copy to clipboard due to security reasons.',
|
||||
color: 'red',
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
title: 'Copied folder link',
|
||||
message: (
|
||||
<>
|
||||
Copied <Link href={`/folder/${folder.id}`}>folder link</Link> to clipboard
|
||||
</>
|
||||
),
|
||||
color: 'green',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LinkIcon />
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='delete' onClick={() => deleteFolder(folder)}>
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
: null
|
||||
: [1, 2, 3, 4].map((x) => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,16 +13,16 @@ import {
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
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 MutedText from 'components/MutedText';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { expireText, relativeTime } from 'lib/utils/client';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { expireText, relativeTime } from 'lib/utils/client';
|
||||
|
||||
const expires = ['30m', '1h', '6h', '12h', '1d', '3d', '5d', '7d', 'never'];
|
||||
|
||||
@@ -38,7 +38,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(
|
||||
@@ -48,6 +48,7 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
|
||||
'6h': Date.now() + 6 * 60 * 60 * 1000,
|
||||
'12h': Date.now() + 12 * 60 * 60 * 1000,
|
||||
'1d': Date.now() + 24 * 60 * 60 * 1000,
|
||||
'3d': Date.now() + 3 * 24 * 60 * 60 * 1000,
|
||||
'5d': Date.now() + 5 * 24 * 60 * 60 * 1000,
|
||||
'7d': Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
}[values.expires]
|
||||
@@ -56,7 +57,7 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
|
||||
setOpen(false);
|
||||
|
||||
const res = await useFetch('/api/auth/invite', 'POST', {
|
||||
expires_at,
|
||||
expiresAt,
|
||||
count: values.count,
|
||||
});
|
||||
|
||||
@@ -107,7 +108,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'>
|
||||
@@ -119,7 +120,7 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function Uz2sers() {
|
||||
export default function Invites() {
|
||||
const router = useRouter();
|
||||
const modals = useModals();
|
||||
const clipboard = useClipboard();
|
||||
@@ -157,11 +158,18 @@ export default function Uz2sers() {
|
||||
|
||||
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: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const updateInvites = async () => {
|
||||
@@ -200,26 +208,26 @@ export default function Uz2sers() {
|
||||
{invite.code}
|
||||
{invite.used && <> (Used)</>}
|
||||
</Title>
|
||||
<Tooltip label={new Date(invite.created_at).toLocaleString()}>
|
||||
<Tooltip label={new Date(invite.createdAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>Created {relativeTime(new Date(invite.created_at))}</MutedText>
|
||||
<MutedText size='sm'>Created {relativeTime(new Date(invite.createdAt))}</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip label={new Date(invite.expires_at).toLocaleString()}>
|
||||
<Tooltip label={new Date(invite.expiresAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>{expireText(invite.expires_at)}</MutedText>
|
||||
<MutedText size='sm'>{expireText(invite.expiresAt)}</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
<Stack>
|
||||
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
|
||||
<CopyIcon />
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Button, Checkbox, Group, Modal, Text, Title } from '@mantine/core';
|
||||
import { closeAllModals, openConfirmModal } from '@mantine/modals';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import { CheckIcon, CrossIcon } from 'components/icons';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
|
||||
export default function ClearStorage({ open, setOpen, check, setCheck }) {
|
||||
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: <CrossIcon />,
|
||||
});
|
||||
} else {
|
||||
updateNotification({
|
||||
id: 'clear-uploads',
|
||||
title: 'Successfully cleared uploads',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(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,3 +1,5 @@
|
||||
import { Code } from '@mantine/core';
|
||||
import Link from 'components/Link';
|
||||
import { GeneratorModal } from './GeneratorModal';
|
||||
|
||||
export default function Flameshot({ user, open, setOpen }) {
|
||||
@@ -5,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'];
|
||||
@@ -38,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
|
||||
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(' ')} | jq -r '.files[0]' | tr -d '\n' | xsel -ib;
|
||||
${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();
|
||||
@@ -68,7 +103,23 @@ ${curl.join(' ')} | jq -r '.files[0]' | tr -d '\n' | xsel -ib;
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title='Flameshot'
|
||||
desc='To use this script, you need Flameshot, curl, jq, and xsel installed. This script is intended for use on Linux only.'
|
||||
desc={
|
||||
<>
|
||||
To use this script, you need <Link href='https://flameshot.org'>Flameshot</Link>,{' '}
|
||||
<Link href='https://curl.se/'>
|
||||
<Code>curl</Code>
|
||||
</Link>
|
||||
,{' '}
|
||||
<Link href='https://github.com/stedolan/jq'>
|
||||
<Code>jq</Code>
|
||||
</Link>
|
||||
, and{' '}
|
||||
<Link href='https://github.com/kfish/xsel'>
|
||||
<Code>xsel</Code>
|
||||
</Link>{' '}
|
||||
installed. This script is intended for use on Linux only.
|
||||
</>
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,105 @@
|
||||
import { Modal, Select, NumberInput, Group, Checkbox, Button, Title, Text } 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 { DownloadIcon, GlobeIcon } from 'components/icons';
|
||||
import Link from 'components/Link';
|
||||
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 && <Text mb='md'>{other.desc}</Text>}
|
||||
{other.desc && (
|
||||
<MutedText size='md' mb='md'>
|
||||
{other.desc}
|
||||
</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={[
|
||||
@@ -25,33 +109,109 @@ export function GeneratorModal({ opened, onClose, title, onSubmit, ...other }) {
|
||||
{ value: 'NAME', label: 'Name (keeps original file name)' },
|
||||
]}
|
||||
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={<GlobeIcon />}
|
||||
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>
|
||||
|
||||
<Group grow>
|
||||
<Button mt='md' onClick={form.reset}>
|
||||
Reset
|
||||
</Button>
|
||||
{title === 'Flameshot' && (
|
||||
<>
|
||||
<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'>
|
||||
<Code>wl-clipboard</Code>
|
||||
</Link>{' '}
|
||||
for the <Code>wl-copy</Code> command.
|
||||
</MutedText>
|
||||
</Box>
|
||||
|
||||
<Button mt='md' rightIcon={<DownloadIcon />} type='submit'>
|
||||
<Group my='md'>
|
||||
<Checkbox
|
||||
label='Enable Wayland Compatibility'
|
||||
description={
|
||||
<>
|
||||
Use <Code>wl-copy</Code> instead of <Code>xsel -ib</Code>
|
||||
</>
|
||||
}
|
||||
id='wlCompatibility'
|
||||
{...form.getInputProps('wlCompatibility', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label={
|
||||
<>
|
||||
Using a DE/compositor that <b>isn't</b> GNOME, KDE or Sway
|
||||
</>
|
||||
}
|
||||
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
|
||||
</>
|
||||
}
|
||||
disabled={!isUploadFile}
|
||||
id='wlCompositorNotSupported'
|
||||
{...form.getInputProps('wlCompositorNotSupported', { type: 'checkbox' })}
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group grow my='md'>
|
||||
<Button onClick={form.reset}>Reset</Button>
|
||||
|
||||
<Button rightIcon={<DownloadIcon />} type='submit'>
|
||||
Download
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1,57 +1,88 @@
|
||||
import { useState } from 'react';
|
||||
import { useReducer, 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 +90,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();
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Button, Center, Image, Modal, NumberInput, Text, Title } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { CheckIcon, CrossIcon } from 'components/icons';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
|
||||
const [secret, setSecret] = useState('');
|
||||
const [qrCode, setQrCode] = useState('');
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [code, setCode] = useState(undefined);
|
||||
const [error, setError] = useState('');
|
||||
const form = useForm();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (opened && !deleteTotp) {
|
||||
const data = await useFetch('/api/user/mfa/totp');
|
||||
if (!data.data_url) {
|
||||
onClose();
|
||||
showNotification({
|
||||
title: 'Error',
|
||||
message: "Can't generate code as you are already using MFA",
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
} else {
|
||||
setSecret(data.secret);
|
||||
setQrCode(data.data_url);
|
||||
setError('');
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [opened]);
|
||||
|
||||
const disableTotp = async () => {
|
||||
setDisabled(true);
|
||||
const str = code.toString();
|
||||
if (str.length !== 6) {
|
||||
return setError('Code must be 6 digits');
|
||||
}
|
||||
|
||||
const resp = await useFetch('/api/user/mfa/totp', 'DELETE', {
|
||||
code: str,
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Success',
|
||||
message: 'Successfully disabled MFA',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
});
|
||||
|
||||
setTotpEnabled(false);
|
||||
|
||||
onClose();
|
||||
}
|
||||
|
||||
setDisabled(false);
|
||||
};
|
||||
|
||||
const verifyCode = async () => {
|
||||
setDisabled(true);
|
||||
const str = code.toString();
|
||||
if (str.length !== 6) {
|
||||
return setError('Code must be 6 digits');
|
||||
}
|
||||
|
||||
const resp = await useFetch('/api/user/mfa/totp', 'POST', {
|
||||
secret,
|
||||
code: str,
|
||||
register: true,
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Success',
|
||||
message: 'Successfully enabled MFA',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
});
|
||||
|
||||
setTotpEnabled(true);
|
||||
|
||||
onClose();
|
||||
}
|
||||
|
||||
setDisabled(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Title order={3}>Two-Factor Authentication</Title>}
|
||||
size='lg'
|
||||
>
|
||||
{deleteTotp ? (
|
||||
<Text mb='md'>Verify your code to disable Two-Factor Authentication</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text mb='md'>
|
||||
Scan the QR Code below in <b>Authy</b>, <b>Google Authenticator</b>, or any other supported
|
||||
client.
|
||||
</Text>
|
||||
<Center>
|
||||
<Image height={180} width={180} src={qrCode} alt='QR Code' withPlaceholder />
|
||||
</Center>
|
||||
<Text my='sm'>QR Code not working? Try manually entering the code into your app: {secret}</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={form.onSubmit(() => {
|
||||
deleteTotp ? disableTotp() : verifyCode();
|
||||
})}
|
||||
>
|
||||
<NumberInput
|
||||
placeholder='2FA Code'
|
||||
label='Verify'
|
||||
size='xl'
|
||||
hideControls
|
||||
maxLength={6}
|
||||
minLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e)}
|
||||
data-autofocus
|
||||
error={error}
|
||||
/>
|
||||
|
||||
<Button
|
||||
disabled={disabled}
|
||||
size='lg'
|
||||
fullWidth
|
||||
mt='md'
|
||||
rightIcon={<CheckIcon />}
|
||||
onClick={deleteTotp ? disableTotp : verifyCode}
|
||||
>
|
||||
Verify{deleteTotp ? ' and Disable' : ''}
|
||||
</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Group,
|
||||
Image,
|
||||
PasswordInput,
|
||||
SimpleGrid,
|
||||
Space,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -15,7 +16,7 @@ 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 {
|
||||
@@ -41,8 +42,10 @@ import { bytesToHuman } from 'lib/utils/bytes';
|
||||
import { capitalize } from 'lib/utils/client';
|
||||
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';
|
||||
|
||||
function ExportDataTooltip({ children }) {
|
||||
return (
|
||||
@@ -56,7 +59,7 @@ function ExportDataTooltip({ children }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function Manage({ oauth_registration, oauth_providers: raw_oauth_providers }) {
|
||||
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,
|
||||
@@ -71,11 +74,15 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
const [user, setUser] = useRecoilState(userSelector);
|
||||
const modals = useModals();
|
||||
|
||||
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 [fileDataURL, setFileDataURL] = useState(user.avatar ?? null);
|
||||
const [totpEnabled, setTotpEnabled] = useState(!!user.totpSecret);
|
||||
const [checked, setCheck] = useState(false);
|
||||
|
||||
const getDataURL = (f: File): Promise<string> => {
|
||||
return new Promise((res, rej) => {
|
||||
@@ -136,9 +143,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(','),
|
||||
},
|
||||
});
|
||||
@@ -146,9 +154,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");
|
||||
|
||||
@@ -163,13 +174,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);
|
||||
@@ -350,14 +359,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'
|
||||
@@ -368,10 +394,52 @@ 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>
|
||||
|
||||
{totp_enabled && (
|
||||
<Box my='md'>
|
||||
<Title>Two Factor Authentication</Title>
|
||||
<MutedText size='md'>
|
||||
{user.totpSecret
|
||||
? 'You have two factor authentication enabled.'
|
||||
: 'You do not have two factor authentication enabled.'}
|
||||
</MutedText>
|
||||
|
||||
<Button
|
||||
size='lg'
|
||||
my='sm'
|
||||
onClick={() => setTotpOpen(true)}
|
||||
sx={{
|
||||
'@media screen and (max-width: 768px)': {
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{totpEnabled ? 'Disable' : 'Enable'} Two Factor Authentication
|
||||
</Button>
|
||||
|
||||
<TotpModal
|
||||
opened={totpOpen}
|
||||
onClose={() => setTotpOpen(false)}
|
||||
deleteTotp={totpEnabled}
|
||||
setTotpEnabled={setTotpEnabled}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{oauth_registration && (
|
||||
<Box my='md'>
|
||||
<Title>OAuth</Title>
|
||||
@@ -426,6 +494,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
'&:hover': {
|
||||
backgroundColor: t.other.hover,
|
||||
},
|
||||
color: t.colorScheme === 'dark' ? 'white' : 'black',
|
||||
})}
|
||||
size='xl'
|
||||
p='sm'
|
||||
@@ -434,7 +503,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Group position='right' mt='md'>
|
||||
<Group position='right' my='md' grow={useMediaQuery('(max-width: 768px)')}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setFile(null);
|
||||
@@ -448,12 +517,12 @@ 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>
|
||||
<Group my='md' grow={useMediaQuery('(max-width: 768px)')}>
|
||||
<Button onClick={openDeleteModal} rightIcon={<DeleteIcon />} color='red'>
|
||||
Delete All Data
|
||||
</Button>
|
||||
@@ -496,26 +565,48 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
{user.administrator && (
|
||||
<Box mt='md'>
|
||||
<Title>Server</Title>
|
||||
<Group>
|
||||
<Group my='md' grow={useMediaQuery('(max-width: 768px)')}>
|
||||
<Button size='md' onClick={forceUpdateStats} color='red' rightIcon={<RefreshIcon />}>
|
||||
Force Update Stats
|
||||
</Button>
|
||||
<Button size='md' onClick={() => setClrStorOpen(true)} color='red' rightIcon={<TrashIcon />}>
|
||||
Delete all uploads
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Title my='md'>Uploaders</Title>
|
||||
<Group>
|
||||
<Button size='xl' onClick={() => setShareXOpen(true)} rightIcon={<ShareXIcon />}>
|
||||
<Button
|
||||
size='xl'
|
||||
onClick={() => setShareXOpen(true)}
|
||||
rightIcon={<ShareXIcon />}
|
||||
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 />}
|
||||
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} check={checked} setCheck={setCheck} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Button, Center, Group, Skeleton, Table, TextInput, Title } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { CopyIcon } from 'components/icons';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function MetadataView({ fileId }) {
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const [metadata, setMetadata] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filtered, setFiltered] = useState([]);
|
||||
|
||||
const getMetadata = async () => {
|
||||
const data = await useFetch(`/api/exif?id=${fileId}`);
|
||||
if (!data.error) {
|
||||
const arr = [];
|
||||
for (const key in data) {
|
||||
arr.push({ name: key, value: data[key] });
|
||||
}
|
||||
setMetadata(arr);
|
||||
} else {
|
||||
setMetadata([]);
|
||||
}
|
||||
};
|
||||
|
||||
const copy = (value) => {
|
||||
clipboard.copy(value);
|
||||
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: value,
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const searchValue = (value) => {
|
||||
setSearch(value);
|
||||
|
||||
const filtered = metadata.filter((item) => {
|
||||
return (
|
||||
item.name.toLowerCase().includes(value.toLowerCase()) ||
|
||||
item.value.toString().toLowerCase().includes(value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
if (filtered.length > 0) {
|
||||
setFiltered(filtered);
|
||||
} else {
|
||||
setFiltered(null);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearch('');
|
||||
setFiltered([]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getMetadata();
|
||||
}, []);
|
||||
|
||||
const rows = (filtered?.length ? filtered : metadata).map((element) => (
|
||||
<tr key={element.name}>
|
||||
<td>{element.name}</td>
|
||||
<td>{element.value}</td>
|
||||
<td>
|
||||
<Button.Group>
|
||||
<Button variant='light' onClick={() => copy(element.value)}>
|
||||
Copy Value
|
||||
</Button>
|
||||
<Button variant='light' onClick={() => copy(element.name)}>
|
||||
Copy Name
|
||||
</Button>
|
||||
</Button.Group>
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group mb='md'>
|
||||
<Title>Metadata for {fileId}</Title>
|
||||
</Group>
|
||||
|
||||
{metadata ? (
|
||||
<>
|
||||
<TextInput
|
||||
my='md'
|
||||
label='Search'
|
||||
labelProps={{
|
||||
size: 'xl',
|
||||
}}
|
||||
placeholder='Search for a metadata value'
|
||||
value={search}
|
||||
onChange={(e) => searchValue(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
{filtered === null ? (
|
||||
<Center>
|
||||
<Group spacing='md'>
|
||||
<Title>No results found</Title>
|
||||
<Button variant='outline' color='red' onClick={clearSearch}>
|
||||
Clear search
|
||||
</Button>
|
||||
</Group>
|
||||
</Center>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Value</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Skeleton height={300} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,184 +1,66 @@
|
||||
import { Box, Card, Grid, LoadingOverlay, MantineTheme, Title, useMantineTheme } from '@mantine/core';
|
||||
import {
|
||||
ArcElement,
|
||||
CategoryScale,
|
||||
Chart as ChartJS,
|
||||
ChartData,
|
||||
ChartOptions,
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip,
|
||||
} from 'chart.js';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
import ColorHash from 'color-hash';
|
||||
import { bytesToHuman } from 'lib/utils/bytes';
|
||||
import { Box, Card, Grid, LoadingOverlay, Title, useMantineTheme } from '@mantine/core';
|
||||
|
||||
import { useStats } from 'lib/queries/stats';
|
||||
import { useMemo } from 'react';
|
||||
import { Chart, Pie } from 'react-chartjs-2';
|
||||
import { bytesToHuman } from 'lib/utils/bytes';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
const hash = new ColorHash();
|
||||
ChartJS.register(ArcElement);
|
||||
ChartJS.register(ChartDataLabels);
|
||||
ChartJS.register(LinearScale);
|
||||
ChartJS.register(CategoryScale, PointElement, LineController, LineElement, Tooltip);
|
||||
|
||||
const CHART_OPTIONS = (theme: MantineTheme): ChartOptions => ({
|
||||
plugins: {
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
intersect: false,
|
||||
},
|
||||
|
||||
datalabels: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
|
||||
scales: {
|
||||
y: {
|
||||
ticks: {
|
||||
callback: (value) => value.toLocaleString(),
|
||||
color: theme.colors.gray[6],
|
||||
},
|
||||
|
||||
grid: {
|
||||
color: theme.colors.gray[8],
|
||||
},
|
||||
},
|
||||
|
||||
x: {
|
||||
ticks: {
|
||||
color: theme.colors.gray[6],
|
||||
},
|
||||
|
||||
grid: {
|
||||
color: theme.colors.gray[8],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type LineChartData = ChartData<'line', number[], string>;
|
||||
type ChartDataMemo = {
|
||||
views: LineChartData;
|
||||
uploads: LineChartData;
|
||||
uploadTypes: ChartData<'pie', number[], string>;
|
||||
storage: LineChartData;
|
||||
} | void;
|
||||
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
export default function Graphs() {
|
||||
const historicalStats = useStats(10);
|
||||
const latest = historicalStats.data?.[0];
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const chartOptions = useMemo(() => CHART_OPTIONS(theme), [theme]);
|
||||
|
||||
const chartData = useMemo<ChartDataMemo>(() => {
|
||||
if (historicalStats.isLoading || !historicalStats.data) return;
|
||||
const chartData = useMemo(() => {
|
||||
if (historicalStats.isLoading || !historicalStats.data) return null;
|
||||
|
||||
const data = Array.from(historicalStats.data).reverse();
|
||||
const labels = data.map((stat) => new Date(stat.created_at).toLocaleDateString());
|
||||
const viewData = data.map((stat) => stat.data.views_count);
|
||||
const uploadData = data.map((stat) => stat.data.count);
|
||||
const storageData = data.map((stat) => stat.data.size_num);
|
||||
|
||||
const views = data.map((stat) => ({
|
||||
date: new Date(stat.createdAt).toLocaleDateString(),
|
||||
views: stat.data.views_count,
|
||||
}));
|
||||
|
||||
const uploads = data.map((stat) => ({
|
||||
date: new Date(stat.createdAt).toLocaleDateString(),
|
||||
uploads: stat.data.count,
|
||||
}));
|
||||
|
||||
const storage = data.map((stat) => ({
|
||||
date: new Date(stat.createdAt).toLocaleDateString(),
|
||||
bytes: stat.data.size_num,
|
||||
}));
|
||||
|
||||
return {
|
||||
views: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Views',
|
||||
data: viewData,
|
||||
borderColor: theme.colors.blue[6],
|
||||
backgroundColor: theme.colors.blue[0],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
uploads: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Uploads',
|
||||
data: uploadData,
|
||||
borderColor: theme.colors.blue[6],
|
||||
backgroundColor: theme.colors.blue[0],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
uploadTypes: {
|
||||
labels: latest?.data.types_count.map((x) => x.mimetype),
|
||||
datasets: [
|
||||
{
|
||||
data: latest?.data.types_count.map((x) => x.count),
|
||||
label: 'Upload Types',
|
||||
backgroundColor: latest?.data.types_count.map((x) => hash.hex(x.mimetype)),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
storage: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Storage',
|
||||
data: storageData,
|
||||
borderColor: theme.colors.blue[6],
|
||||
backgroundColor: theme.colors.blue[0],
|
||||
},
|
||||
],
|
||||
},
|
||||
views,
|
||||
uploads,
|
||||
storage,
|
||||
};
|
||||
}, [historicalStats]);
|
||||
|
||||
return (
|
||||
return historicalStats.isLoading ? (
|
||||
<LoadingOverlay visible={historicalStats.isLoading} />
|
||||
) : (
|
||||
<Box mt='md'>
|
||||
<LoadingOverlay visible={historicalStats.isLoading} />
|
||||
|
||||
<Grid>
|
||||
{/* 1/4 - upload types */}
|
||||
<Grid.Col md={12} lg={4}>
|
||||
<Card>
|
||||
<Title size='h4'>Upload Types</Title>
|
||||
{chartData && (
|
||||
<Pie
|
||||
data={chartData.uploadTypes}
|
||||
options={{
|
||||
plugins: {
|
||||
datalabels: {
|
||||
formatter: (_, ctx) => {
|
||||
// mime: count
|
||||
const mime = ctx.chart.data.labels[ctx.dataIndex];
|
||||
const count = ctx.chart.data.datasets[0].data[ctx.dataIndex];
|
||||
return `${mime}: ${count}`;
|
||||
},
|
||||
|
||||
color: 'white',
|
||||
textShadowBlur: 7,
|
||||
textShadowColor: 'black',
|
||||
},
|
||||
},
|
||||
}}
|
||||
style={{ maxHeight: '20vh' }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
{/* 3/4 - views */}
|
||||
<Grid.Col md={12} lg={8}>
|
||||
<Grid.Col md={12}>
|
||||
<Card>
|
||||
<Title size='h4'>Total Views</Title>
|
||||
{chartData && (
|
||||
<Chart
|
||||
type='line'
|
||||
data={chartData.views}
|
||||
options={chartOptions}
|
||||
style={{ maxHeight: '20vh' }}
|
||||
/>
|
||||
<ResponsiveContainer width='100%' height={250}>
|
||||
<LineChart data={chartData.views}>
|
||||
<CartesianGrid strokeDasharray='3 3' />
|
||||
<XAxis dataKey='date' />
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : 'white',
|
||||
borderColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : 'white',
|
||||
}}
|
||||
/>
|
||||
<Line type='monotone' dataKey='views' name='Views' stroke='#8884d8' activeDot={{ r: 8 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
@@ -188,12 +70,26 @@ export default function Graphs() {
|
||||
<Card>
|
||||
<Title size='h4'>Total Uploads</Title>
|
||||
{chartData && (
|
||||
<Chart
|
||||
type='line'
|
||||
data={chartData.uploads}
|
||||
options={chartOptions}
|
||||
style={{ maxHeight: '20vh' }}
|
||||
/>
|
||||
<ResponsiveContainer width='100%' height={250}>
|
||||
<LineChart data={chartData.uploads}>
|
||||
<CartesianGrid strokeDasharray='3 3' />
|
||||
<XAxis dataKey='date' />
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : 'white',
|
||||
borderColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : 'white',
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type='monotone'
|
||||
dataKey='uploads'
|
||||
name='Uploads'
|
||||
stroke='#8884d8'
|
||||
activeDot={{ r: 8 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
@@ -203,39 +99,27 @@ export default function Graphs() {
|
||||
<Card>
|
||||
<Title size='h4'>Storage Usage</Title>
|
||||
{chartData && (
|
||||
<Chart
|
||||
type='line'
|
||||
data={chartData.storage}
|
||||
options={{
|
||||
...chartOptions,
|
||||
|
||||
scales: {
|
||||
...chartOptions.scales,
|
||||
y: {
|
||||
...chartOptions.scales.y,
|
||||
|
||||
ticks: {
|
||||
callback: (value) => bytesToHuman(value as number),
|
||||
color: theme.colors.gray[6],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
plugins: {
|
||||
...chartOptions.plugins,
|
||||
tooltip: {
|
||||
...chartOptions.plugins.tooltip,
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const value = context.raw as number;
|
||||
return bytesToHuman(value);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
style={{ maxHeight: '20vh' }}
|
||||
/>
|
||||
<ResponsiveContainer width='100%' height={250}>
|
||||
<LineChart data={chartData.storage}>
|
||||
<CartesianGrid strokeDasharray='3 3' />
|
||||
<XAxis dataKey='date' />
|
||||
<YAxis width={80} tickFormatter={(value) => bytesToHuman(value as number)} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : 'white',
|
||||
borderColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[3],
|
||||
}}
|
||||
formatter={(value) => bytesToHuman(value as number)}
|
||||
/>
|
||||
<Line
|
||||
type='monotone'
|
||||
stroke='#8884d8'
|
||||
activeDot={{ r: 8 }}
|
||||
dataKey='bytes'
|
||||
name='Storage'
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -1,15 +1,39 @@
|
||||
import { LoadingOverlay, Card, Box } from '@mantine/core';
|
||||
import { Box, Card, Center, Grid, LoadingOverlay, Title, useMantineTheme } from '@mantine/core';
|
||||
|
||||
import { SmallTable } from 'components/SmallTable';
|
||||
import { useStats } from 'lib/queries/stats';
|
||||
import { colorHash } from 'lib/utils/client';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
|
||||
export default function Types() {
|
||||
const stats = useStats();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
if (stats.isLoading) return <LoadingOverlay visible />;
|
||||
const latest = useMemo(() => {
|
||||
if (stats.isLoading || !stats.data) return null;
|
||||
|
||||
const latest = stats.data[0];
|
||||
return stats.data[0];
|
||||
}, [stats]);
|
||||
|
||||
return (
|
||||
const chartData = useMemo(() => {
|
||||
if (!latest) return null;
|
||||
|
||||
const data = latest.data.types_count.map((type) => ({
|
||||
name: type.mimetype,
|
||||
value: type.count,
|
||||
fill: colorHash(type.mimetype),
|
||||
}));
|
||||
|
||||
return {
|
||||
data,
|
||||
};
|
||||
}, [latest]);
|
||||
|
||||
return !latest ? (
|
||||
<LoadingOverlay visible={stats.isLoading} />
|
||||
) : (
|
||||
<Box mt='md'>
|
||||
{latest.data.count_by_user.length ? (
|
||||
<Card>
|
||||
@@ -23,13 +47,48 @@ export default function Types() {
|
||||
</Card>
|
||||
) : null}
|
||||
<Card>
|
||||
<SmallTable
|
||||
columns={[
|
||||
{ id: 'mimetype', name: 'Type' },
|
||||
{ id: 'count', name: 'Count' },
|
||||
]}
|
||||
rows={latest.data.types_count}
|
||||
/>
|
||||
<Title size='h4'>Upload Types</Title>
|
||||
<Grid>
|
||||
<Grid.Col md={12} lg={8}>
|
||||
<SmallTable
|
||||
columns={[
|
||||
{ id: 'mimetype', name: 'Type' },
|
||||
{ id: 'count', name: 'Count' },
|
||||
]}
|
||||
rows={latest.data.types_count}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col md={12} lg={4}>
|
||||
<Center>
|
||||
{chartData && (
|
||||
<ResponsiveContainer width='100%' height={250}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData.data}
|
||||
dataKey='value'
|
||||
nameKey='name'
|
||||
cx='50%'
|
||||
cy='50%'
|
||||
outerRadius={80}
|
||||
label={({ name, value }) => `${name} (${value})`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : 'white',
|
||||
borderColor:
|
||||
theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[3],
|
||||
}}
|
||||
itemStyle={{
|
||||
color: theme.colorScheme === 'dark' ? 'white' : 'black',
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Center>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
Group,
|
||||
Progress,
|
||||
Select,
|
||||
Title,
|
||||
PasswordInput,
|
||||
Tooltip,
|
||||
NumberInput,
|
||||
} from '@mantine/core';
|
||||
import { randomId, useClipboard } from '@mantine/hooks';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import Dropzone from 'components/dropzone/Dropzone';
|
||||
import FileDropzone from 'components/dropzone/DropzoneFile';
|
||||
import { ClockIcon, CrossIcon, UploadIcon } from 'components/icons';
|
||||
import Link from 'components/Link';
|
||||
import { invalidateFiles } from 'lib/queries/files';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { randomChars } from 'lib/utils/client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
export default function Upload({ chunks: chunks_config }) {
|
||||
const clipboard = useClipboard();
|
||||
const user = useRecoilValue(userSelector);
|
||||
|
||||
const [files, setFiles] = useState([]);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expires, setExpires] = useState('never');
|
||||
const [password, setPassword] = useState('');
|
||||
const [maxViews, setMaxViews] = useState<number>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('paste', (e: ClipboardEvent) => {
|
||||
const item = Array.from(e.clipboardData.items).find((x) => /^image/.test(x.type));
|
||||
const file = item.getAsFile();
|
||||
setFiles([...files, file]);
|
||||
showNotification({
|
||||
title: 'Image imported from clipboard',
|
||||
message: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const handleChunkedFiles = async (expires_at: Date, toChunkFiles: File[]) => {
|
||||
for (let i = 0; i !== toChunkFiles.length; ++i) {
|
||||
const file = toChunkFiles[i];
|
||||
const identifier = randomChars(4);
|
||||
|
||||
const nChunks = Math.ceil(file.size / chunks_config.chunks_size);
|
||||
const chunks: {
|
||||
blob: Blob;
|
||||
start: number;
|
||||
end: number;
|
||||
}[] = [];
|
||||
|
||||
for (let j = 0; j !== nChunks; ++j) {
|
||||
const chunk = file.slice(j * chunks_config.chunks_size, (j + 1) * chunks_config.chunks_size);
|
||||
chunks.push({
|
||||
blob: chunk,
|
||||
start: j * chunks_config.chunks_size,
|
||||
end: (j + 1) * chunks_config.chunks_size,
|
||||
});
|
||||
}
|
||||
|
||||
let ready = true;
|
||||
for (let j = 0; j !== chunks.length; ++j) {
|
||||
while (!ready) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
|
||||
body.append('file', chunks[j].blob);
|
||||
|
||||
setProgress(0);
|
||||
setLoading(true);
|
||||
|
||||
const req = new XMLHttpRequest();
|
||||
req.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
setProgress(Math.round((e.loaded / e.total) * 100));
|
||||
}
|
||||
});
|
||||
|
||||
req.addEventListener(
|
||||
'load',
|
||||
(e) => {
|
||||
// @ts-ignore not sure why it thinks response doesnt exist, but it does.
|
||||
const json = JSON.parse(e.target.response);
|
||||
|
||||
if (json.error === undefined) {
|
||||
updateNotification({
|
||||
id: 'upload-chunked',
|
||||
title: `Uploading chunk ${j + 1}/${chunks.length} Successful`,
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <UploadIcon />,
|
||||
});
|
||||
|
||||
if (j === chunks.length - 1) {
|
||||
updateNotification({
|
||||
id: 'upload-chunked',
|
||||
title: 'Upload Successful',
|
||||
message: (
|
||||
<>
|
||||
Copied first file to clipboard! <br />
|
||||
{json.files.map((x) => (
|
||||
<Link key={x} href={x}>
|
||||
{x}
|
||||
<br />
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
color: 'green',
|
||||
icon: <UploadIcon />,
|
||||
});
|
||||
|
||||
invalidateFiles();
|
||||
setFiles([]);
|
||||
|
||||
clipboard.copy(json.files[0]);
|
||||
}
|
||||
|
||||
ready = true;
|
||||
} else {
|
||||
updateNotification({
|
||||
id: 'upload-chunked',
|
||||
title: `Uploading chunk ${j + 1}/${chunks.length} Failed`,
|
||||
message: json.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
ready = false;
|
||||
}
|
||||
setProgress(0);
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
req.open('POST', '/api/upload');
|
||||
req.setRequestHeader('Authorization', user.token);
|
||||
req.setRequestHeader('Content-Range', `bytes ${chunks[j].start}-${chunks[j].end}/${file.size}`);
|
||||
req.setRequestHeader('X-Zipline-Partial-FileName', file.name);
|
||||
req.setRequestHeader('X-Zipline-Partial-MimeType', file.type);
|
||||
req.setRequestHeader('X-Zipline-Partial-Identifier', identifier);
|
||||
req.setRequestHeader('X-Zipline-Partial-LastChunk', j === chunks.length - 1 ? 'true' : 'false');
|
||||
expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expires_at.toISOString());
|
||||
password !== '' && req.setRequestHeader('Password', password);
|
||||
maxViews && maxViews !== 0 && req.setRequestHeader('Max-Views', String(maxViews));
|
||||
|
||||
req.send(body);
|
||||
|
||||
ready = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
const expires_at =
|
||||
expires === 'never'
|
||||
? null
|
||||
: new Date(
|
||||
{
|
||||
'5min': Date.now() + 5 * 60 * 1000,
|
||||
'10min': Date.now() + 10 * 60 * 1000,
|
||||
'15min': Date.now() + 15 * 60 * 1000,
|
||||
'30min': Date.now() + 30 * 60 * 1000,
|
||||
'1h': Date.now() + 60 * 60 * 1000,
|
||||
'2h': Date.now() + 2 * 60 * 60 * 1000,
|
||||
'3h': Date.now() + 3 * 60 * 60 * 1000,
|
||||
'4h': Date.now() + 4 * 60 * 60 * 1000,
|
||||
'5h': Date.now() + 5 * 60 * 60 * 1000,
|
||||
'6h': Date.now() + 6 * 60 * 60 * 1000,
|
||||
'8h': Date.now() + 8 * 60 * 60 * 1000,
|
||||
'12h': Date.now() + 12 * 60 * 60 * 1000,
|
||||
'1d': Date.now() + 24 * 60 * 60 * 1000,
|
||||
'3d': Date.now() + 3 * 24 * 60 * 60 * 1000,
|
||||
'5d': Date.now() + 5 * 24 * 60 * 60 * 1000,
|
||||
'7d': Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
'1w': Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
'1.5w': Date.now() + 1.5 * 7 * 24 * 60 * 60 * 1000,
|
||||
'2w': Date.now() + 2 * 7 * 24 * 60 * 60 * 1000,
|
||||
'3w': Date.now() + 3 * 7 * 24 * 60 * 60 * 1000,
|
||||
'1m': Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||
'1.5m': Date.now() + 1.5 * 30 * 24 * 60 * 60 * 1000,
|
||||
'2m': Date.now() + 2 * 30 * 24 * 60 * 60 * 1000,
|
||||
'3m': Date.now() + 3 * 30 * 24 * 60 * 60 * 1000,
|
||||
'6m': Date.now() + 6 * 30 * 24 * 60 * 60 * 1000,
|
||||
'8m': Date.now() + 8 * 30 * 24 * 60 * 60 * 1000,
|
||||
'1y': Date.now() + 365 * 24 * 60 * 60 * 1000,
|
||||
}[expires]
|
||||
);
|
||||
|
||||
setProgress(0);
|
||||
setLoading(true);
|
||||
const body = new FormData();
|
||||
const toChunkFiles = [];
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
const file = files[i];
|
||||
if (file.size >= chunks_config.max_size) {
|
||||
toChunkFiles.push(file);
|
||||
} else {
|
||||
body.append('file', files[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const bodyLength = body.getAll('file').length;
|
||||
|
||||
if (bodyLength === 0 && toChunkFiles.length) {
|
||||
showNotification({
|
||||
id: 'upload-chunked',
|
||||
title: 'Uploading chunked files',
|
||||
message: '',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
return handleChunkedFiles(expires_at, toChunkFiles);
|
||||
}
|
||||
|
||||
showNotification({
|
||||
id: 'upload',
|
||||
title: 'Uploading files...',
|
||||
message: '',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
const req = new XMLHttpRequest();
|
||||
req.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
setProgress(Math.round((e.loaded / e.total) * 100));
|
||||
}
|
||||
});
|
||||
|
||||
req.addEventListener(
|
||||
'load',
|
||||
(e) => {
|
||||
// @ts-ignore not sure why it thinks response doesnt exist, but it does.
|
||||
const json = JSON.parse(e.target.response);
|
||||
setLoading(false);
|
||||
|
||||
if (json.error === undefined) {
|
||||
updateNotification({
|
||||
id: 'upload',
|
||||
title: 'Upload Successful',
|
||||
message: (
|
||||
<>
|
||||
Copied first file to clipboard! <br />
|
||||
{json.files.map((x) => (
|
||||
<Link key={x} href={x}>
|
||||
{x}
|
||||
<br />
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
color: 'green',
|
||||
icon: <UploadIcon />,
|
||||
});
|
||||
clipboard.copy(json.files[0]);
|
||||
setFiles([]);
|
||||
invalidateFiles();
|
||||
|
||||
if (toChunkFiles.length) {
|
||||
showNotification({
|
||||
id: 'upload-chunked',
|
||||
title: 'Uploading chunked files',
|
||||
message: '',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
return handleChunkedFiles(expires_at, toChunkFiles);
|
||||
}
|
||||
} else {
|
||||
updateNotification({
|
||||
id: 'upload',
|
||||
title: 'Upload Failed',
|
||||
message: json.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
setProgress(0);
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
if (bodyLength !== 0) {
|
||||
req.open('POST', '/api/upload');
|
||||
req.setRequestHeader('Authorization', user.token);
|
||||
expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expires_at.toISOString());
|
||||
password !== '' && req.setRequestHeader('Password', password);
|
||||
maxViews && maxViews !== 0 && req.setRequestHeader('Max-Views', String(maxViews));
|
||||
|
||||
req.send(body);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title mb='md'>Upload Files</Title>
|
||||
|
||||
<Dropzone loading={loading} onDrop={(f) => setFiles([...files, ...f])}>
|
||||
<Group position='center' spacing='md'>
|
||||
{files.map((file) => (
|
||||
<FileDropzone key={randomId()} file={file} />
|
||||
))}
|
||||
</Group>
|
||||
</Dropzone>
|
||||
|
||||
<Collapse in={progress !== 0}>
|
||||
{progress !== 0 && <Progress mt='md' value={progress} animate />}
|
||||
</Collapse>
|
||||
|
||||
<Group position='right' mt='md'>
|
||||
<Tooltip label='After the file reaches this amount of views, it will be deleted automatically. Leave blank for no limit.'>
|
||||
<NumberInput placeholder='Max Views' min={0} value={maxViews} onChange={(x) => setMaxViews(x)} />
|
||||
</Tooltip>
|
||||
<Tooltip label='Add a password to your files (optional, leave blank for none)'>
|
||||
<PasswordInput
|
||||
style={{ width: '252px' }}
|
||||
placeholder='Password'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label='Set an expiration date for your files (optional, defaults to never)'>
|
||||
<Select
|
||||
value={expires}
|
||||
onChange={(e) => setExpires(e)}
|
||||
icon={<ClockIcon size={14} />}
|
||||
data={[
|
||||
{ value: 'never', label: 'Never' },
|
||||
{ value: '5min', label: '5 minutes' },
|
||||
{ value: '10min', label: '10 minutes' },
|
||||
{ value: '15min', label: '15 minutes' },
|
||||
{ value: '30min', label: '30 minutes' },
|
||||
{ value: '1h', label: '1 hour' },
|
||||
{ value: '2h', label: '2 hours' },
|
||||
{ value: '3h', label: '3 hours' },
|
||||
{ value: '4h', label: '4 hours' },
|
||||
{ value: '5h', label: '5 hours' },
|
||||
{ value: '6h', label: '6 hours' },
|
||||
{ value: '8h', label: '8 hours' },
|
||||
{ value: '12h', label: '12 hours' },
|
||||
{ value: '1d', label: '1 day' },
|
||||
{ value: '3d', label: '3 days' },
|
||||
{ value: '5d', label: '5 days' },
|
||||
{ value: '7d', label: '7 days' },
|
||||
{ value: '1w', label: '1 week' },
|
||||
{ value: '1.5w', label: '1.5 weeks' },
|
||||
{ value: '2w', label: '2 weeks' },
|
||||
{ value: '3w', label: '3 weeks' },
|
||||
{ value: '1m', label: '1 month' },
|
||||
{ value: '1.5m', label: '1.5 months' },
|
||||
{ value: '2m', label: '2 months' },
|
||||
{ value: '3m', label: '3 months' },
|
||||
{ value: '6m', label: '6 months' },
|
||||
{ value: '8m', label: '8 months' },
|
||||
{ value: '1y', label: '1 year' },
|
||||
]}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button leftIcon={<UploadIcon />} onClick={handleUpload} disabled={files.length === 0 ? true : false}>
|
||||
Upload
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Collapse,
|
||||
Group,
|
||||
NumberInput,
|
||||
PasswordInput,
|
||||
Progress,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { randomId, useClipboard } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import Dropzone from 'components/dropzone/Dropzone';
|
||||
import FileDropzone from 'components/dropzone/DropzoneFile';
|
||||
import { ClockIcon, CrossIcon, UploadIcon } from 'components/icons';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { invalidateFiles } from 'lib/queries/files';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { expireReadToDate, randomChars } from 'lib/utils/client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import showFilesModal from './showFilesModal';
|
||||
import useUploadOptions from './useUploadOptions';
|
||||
|
||||
export default function File({ chunks: chunks_config }) {
|
||||
const clipboard = useClipboard();
|
||||
const modals = useModals();
|
||||
const user = useRecoilValue(userSelector);
|
||||
|
||||
const [files, setFiles] = useState([]);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [options, setOpened, OptionsModal] = useUploadOptions();
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('paste', (e: ClipboardEvent) => {
|
||||
const item = Array.from(e.clipboardData.items).find((x) => /^image/.test(x.type));
|
||||
const file = item.getAsFile();
|
||||
setFiles([...files, file]);
|
||||
showNotification({
|
||||
title: 'Image imported from clipboard',
|
||||
message: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const handleChunkedFiles = async (expiresAt: Date, toChunkFiles: File[]) => {
|
||||
for (let i = 0; i !== toChunkFiles.length; ++i) {
|
||||
const file = toChunkFiles[i];
|
||||
const identifier = randomChars(4);
|
||||
|
||||
const nChunks = Math.ceil(file.size / chunks_config.chunks_size);
|
||||
const chunks: {
|
||||
blob: Blob;
|
||||
start: number;
|
||||
end: number;
|
||||
}[] = [];
|
||||
|
||||
for (let j = 0; j !== nChunks; ++j) {
|
||||
const chunk = file.slice(j * chunks_config.chunks_size, (j + 1) * chunks_config.chunks_size);
|
||||
chunks.push({
|
||||
blob: chunk,
|
||||
start: j * chunks_config.chunks_size,
|
||||
end: (j + 1) * chunks_config.chunks_size,
|
||||
});
|
||||
}
|
||||
|
||||
let ready = true;
|
||||
for (let j = 0; j !== chunks.length; ++j) {
|
||||
while (!ready) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
// if last chunk send notif that it will take a while
|
||||
if (j === chunks.length - 1) {
|
||||
updateNotification({
|
||||
id: 'upload-chunked',
|
||||
title: 'Finalizing partial upload',
|
||||
message: 'This may take a while...',
|
||||
icon: <ClockIcon />,
|
||||
color: 'yellow',
|
||||
autoClose: false,
|
||||
});
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
body.append('file', chunks[j].blob);
|
||||
|
||||
setLoading(true);
|
||||
const req = new XMLHttpRequest();
|
||||
|
||||
req.addEventListener(
|
||||
'load',
|
||||
(e) => {
|
||||
// @ts-ignore not sure why it thinks response doesnt exist, but it does.
|
||||
const json = JSON.parse(e.target.response);
|
||||
|
||||
if (json.error === undefined) {
|
||||
setProgress(Math.round((j / chunks.length) * 100));
|
||||
updateNotification({
|
||||
id: 'upload-chunked',
|
||||
title: `Uploading chunk ${j + 1}/${chunks.length} Successful`,
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <UploadIcon />,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
if (j === chunks.length - 1) {
|
||||
updateNotification({
|
||||
id: 'upload-chunked',
|
||||
title: 'Upload Successful',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <UploadIcon />,
|
||||
});
|
||||
showFilesModal(clipboard, modals, json.files);
|
||||
invalidateFiles();
|
||||
setFiles([]);
|
||||
setProgress(100);
|
||||
|
||||
setTimeout(() => setProgress(0), 1000);
|
||||
|
||||
clipboard.copy(json.files[0]);
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: 'Zipline is unable to copy to clipboard due to security reasons.',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
|
||||
ready = true;
|
||||
} else {
|
||||
updateNotification({
|
||||
id: 'upload-chunked',
|
||||
title: `Uploading chunk ${j + 1}/${chunks.length} Failed`,
|
||||
message: json.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
autoClose: false,
|
||||
});
|
||||
ready = false;
|
||||
}
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
req.open('POST', '/api/upload');
|
||||
req.setRequestHeader('Authorization', user.token);
|
||||
req.setRequestHeader('Content-Range', `bytes ${chunks[j].start}-${chunks[j].end}/${file.size}`);
|
||||
req.setRequestHeader('X-Zipline-Partial-FileName', file.name);
|
||||
req.setRequestHeader('X-Zipline-Partial-MimeType', file.type);
|
||||
req.setRequestHeader('X-Zipline-Partial-Identifier', identifier);
|
||||
req.setRequestHeader('X-Zipline-Partial-LastChunk', j === chunks.length - 1 ? 'true' : 'false');
|
||||
options.expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expiresAt.toISOString());
|
||||
options.password.trim() !== '' && req.setRequestHeader('Password', options.password);
|
||||
options.maxViews &&
|
||||
options.maxViews !== 0 &&
|
||||
req.setRequestHeader('Max-Views', String(options.maxViews));
|
||||
options.compression !== 'none' &&
|
||||
req.setRequestHeader('Image-Compression-Percent', options.compression);
|
||||
options.embedded && req.setRequestHeader('Embed', 'true');
|
||||
options.zeroWidth && req.setRequestHeader('Zws', 'true');
|
||||
options.format !== 'default' && req.setRequestHeader('Format', options.format);
|
||||
options.originalName && req.setRequestHeader('Original-Name', 'true');
|
||||
|
||||
req.send(body);
|
||||
|
||||
ready = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
const expiresAt = options.expires === 'never' ? null : expireReadToDate(options.expires);
|
||||
|
||||
setProgress(0);
|
||||
setLoading(true);
|
||||
const body = new FormData();
|
||||
const toChunkFiles = [];
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
const file = files[i];
|
||||
if (file.size >= chunks_config.max_size) {
|
||||
toChunkFiles.push(file);
|
||||
} else {
|
||||
body.append('file', files[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const bodyLength = body.getAll('file').length;
|
||||
|
||||
if (bodyLength === 0 && toChunkFiles.length) {
|
||||
showNotification({
|
||||
id: 'upload-chunked',
|
||||
title: 'Uploading chunked files',
|
||||
message: '',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
return handleChunkedFiles(expiresAt, toChunkFiles);
|
||||
}
|
||||
|
||||
showNotification({
|
||||
id: 'upload',
|
||||
title: 'Uploading files...',
|
||||
message: '',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
const req = new XMLHttpRequest();
|
||||
req.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
setProgress(Math.round((e.loaded / e.total) * 100));
|
||||
}
|
||||
});
|
||||
|
||||
req.addEventListener(
|
||||
'load',
|
||||
(e) => {
|
||||
// @ts-ignore not sure why it thinks response doesnt exist, but it does.
|
||||
const json = JSON.parse(e.target.response);
|
||||
setLoading(false);
|
||||
|
||||
if (!json.error) {
|
||||
updateNotification({
|
||||
id: 'upload',
|
||||
title: 'Upload Successful',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <UploadIcon />,
|
||||
});
|
||||
showFilesModal(clipboard, modals, json.files);
|
||||
setFiles([]);
|
||||
invalidateFiles();
|
||||
|
||||
if (toChunkFiles.length) {
|
||||
showNotification({
|
||||
id: 'upload-chunked',
|
||||
title: 'Uploading chunked files',
|
||||
message: '',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
return handleChunkedFiles(expiresAt, toChunkFiles);
|
||||
}
|
||||
} else {
|
||||
updateNotification({
|
||||
id: 'upload',
|
||||
title: 'Upload Failed',
|
||||
message: json.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
setProgress(0);
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
if (bodyLength !== 0) {
|
||||
req.open('POST', '/api/upload');
|
||||
req.setRequestHeader('Authorization', user.token);
|
||||
options.expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expiresAt.toISOString());
|
||||
options.password.trim() !== '' && req.setRequestHeader('Password', options.password);
|
||||
options.maxViews &&
|
||||
options.maxViews !== 0 &&
|
||||
req.setRequestHeader('Max-Views', String(options.maxViews));
|
||||
options.compression !== 'none' &&
|
||||
req.setRequestHeader('Image-Compression-Percent', options.compression);
|
||||
options.embedded && req.setRequestHeader('Embed', 'true');
|
||||
options.zeroWidth && req.setRequestHeader('Zws', 'true');
|
||||
options.format !== 'default' && req.setRequestHeader('Format', options.format);
|
||||
options.originalName && req.setRequestHeader('Original-Name', 'true');
|
||||
options.overrideDomain && req.setRequestHeader('Override-Domain', options.overrideDomain);
|
||||
|
||||
req.send(body);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{OptionsModal}
|
||||
<Title mb='md'>Upload Files</Title>
|
||||
|
||||
<Dropzone loading={loading} onDrop={(f) => setFiles([...files, ...f])}>
|
||||
<Stack justify='space-between' h='100%'>
|
||||
{files.length ? (
|
||||
<Group spacing='md'>
|
||||
{files.map((file) => (
|
||||
<FileDropzone
|
||||
key={randomId()}
|
||||
file={file}
|
||||
onRemove={() => setFiles(files.filter((f) => f !== file))}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
) : (
|
||||
<Group position='center'>
|
||||
<MutedText>Files will appear here once you drop/select them</MutedText>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Stack>
|
||||
<Group position='right' mt='md'>
|
||||
<Button onClick={() => setOpened(true)} variant='outline'>
|
||||
Options
|
||||
</Button>
|
||||
<Button onClick={() => setFiles([])} color='red' variant='outline'>
|
||||
Clear Files
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<UploadIcon />}
|
||||
onClick={handleUpload}
|
||||
disabled={files.length === 0 ? true : false}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Collapse in={progress !== 0}>
|
||||
{progress !== 0 && <Progress mt='md' value={progress} animate />}
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Dropzone>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Alert, Button, Card, Container, Group, Select, Tabs, Title } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import CodeInput from 'components/CodeInput';
|
||||
import { ImageIcon, TypeIcon, UploadIcon } from 'components/icons';
|
||||
import KaTeX from 'components/render/KaTeX';
|
||||
import Markdown from 'components/render/Markdown';
|
||||
import PrismCode from 'components/render/PrismCode';
|
||||
import exts from 'lib/exts';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { expireReadToDate } from 'lib/utils/client';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import showFilesModal from './showFilesModal';
|
||||
import useUploadOptions from './useUploadOptions';
|
||||
|
||||
export default function Text() {
|
||||
const clipboard = useClipboard();
|
||||
const modals = useModals();
|
||||
const user = useRecoilValue(userSelector);
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
const [lang, setLang] = useState('txt');
|
||||
|
||||
const [options, setOpened, OptionsModal] = useUploadOptions();
|
||||
|
||||
const shouldRenderMarkdown = lang === 'md';
|
||||
const shouldRenderTex = lang === 'tex';
|
||||
|
||||
const handleUpload = async () => {
|
||||
const file = new File([value], 'text.' + lang);
|
||||
|
||||
const expiresAt = options.expires === 'never' ? null : expireReadToDate(options.expires);
|
||||
|
||||
showNotification({
|
||||
id: 'upload-text',
|
||||
title: 'Uploading...',
|
||||
message: '',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
const req = new XMLHttpRequest();
|
||||
req.addEventListener('load', (e) => {
|
||||
// @ts-ignore not sure why it thinks response doesnt exist, but it does.
|
||||
const json = JSON.parse(e.target.response);
|
||||
|
||||
if (!json.error) {
|
||||
updateNotification({
|
||||
id: 'upload-text',
|
||||
title: 'Upload Successful',
|
||||
message: '',
|
||||
});
|
||||
showFilesModal(clipboard, modals, json.files);
|
||||
}
|
||||
});
|
||||
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
|
||||
req.open('POST', '/api/upload');
|
||||
req.setRequestHeader('Authorization', user.token);
|
||||
req.setRequestHeader('UploadText', 'true');
|
||||
|
||||
options.expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expiresAt.toISOString());
|
||||
options.password.trim() !== '' && req.setRequestHeader('Password', options.password);
|
||||
options.maxViews && options.maxViews !== 0 && req.setRequestHeader('Max-Views', String(options.maxViews));
|
||||
options.compression !== 'none' && req.setRequestHeader('Image-Compression-Percent', options.compression);
|
||||
options.embedded && req.setRequestHeader('Embed', 'true');
|
||||
options.zeroWidth && req.setRequestHeader('Zws', 'true');
|
||||
options.format !== 'default' && req.setRequestHeader('Format', options.format);
|
||||
options.originalName && req.setRequestHeader('Original-Name', 'true');
|
||||
options.overrideDomain && req.setRequestHeader('Override-Domain', options.overrideDomain);
|
||||
|
||||
req.send(body);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{OptionsModal}
|
||||
<Title mb='md'>Upload Text</Title>
|
||||
|
||||
<Tabs defaultValue='text' variant='pills'>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value='text' icon={<TypeIcon />}>
|
||||
Text
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value='preview' icon={<ImageIcon />}>
|
||||
Preview
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel mt='sm' value='text'>
|
||||
<CodeInput value={value} onChange={(e) => setValue(e.target.value)} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel mt='sm' value='preview'>
|
||||
{shouldRenderMarkdown || shouldRenderTex ? (
|
||||
<>
|
||||
<Alert color='blue' variant='outline' sx={{ width: '100%' }}>
|
||||
You are viewing a rendered version of your code
|
||||
</Alert>
|
||||
|
||||
<Container>
|
||||
<Card p='md' my='sm'>
|
||||
{shouldRenderMarkdown && <Markdown code={value} />}
|
||||
{shouldRenderTex && <KaTeX code={value} />}
|
||||
</Card>
|
||||
</Container>
|
||||
</>
|
||||
) : (
|
||||
<PrismCode
|
||||
sx={(t) => ({ height: '100vh', backgroundColor: t.colors.dark[8] })}
|
||||
code={value}
|
||||
ext={lang}
|
||||
/>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Group position='right' mt='md'>
|
||||
<Select
|
||||
value={lang}
|
||||
onChange={setLang}
|
||||
dropdownPosition='top'
|
||||
data={Object.keys(exts).map((x) => ({ value: x, label: exts[x] }))}
|
||||
icon={<TypeIcon />}
|
||||
searchable
|
||||
/>
|
||||
|
||||
<Button onClick={() => setOpened(true)} variant='outline'>
|
||||
Options
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
leftIcon={<UploadIcon />}
|
||||
onClick={handleUpload}
|
||||
disabled={value.trim().length === 0 ? true : false}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ActionIcon, Box, Button, Group, Stack, Table, Title, Tooltip } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { CopyIcon, LinkIcon } from 'components/icons';
|
||||
import Link from 'components/Link';
|
||||
|
||||
export default function showFilesModal(clipboard, modals, files: string[]) {
|
||||
const open = (idx: number) => window.open(files[idx], '_blank');
|
||||
const copy = (idx: number) => {
|
||||
clipboard.copy(files[idx]);
|
||||
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: <Link href={files[idx]}>{files[idx]}</Link>,
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
modals.openModal({
|
||||
title: <Title>Uploaded Files</Title>,
|
||||
size: 'auto',
|
||||
children: (
|
||||
<Table withBorder={false} withColumnBorders={false} highlightOnHover horizontalSpacing={'sm'}>
|
||||
<Stack>
|
||||
{files.map((file, idx) => (
|
||||
<Group key={idx} position='apart'>
|
||||
<Group position='left'>
|
||||
<Link href={file}>{file}</Link>
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
<Tooltip label='Open link in a new tab'>
|
||||
<ActionIcon onClick={() => open(idx)} variant='filled' color='primary'>
|
||||
<LinkIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Copy link to clipboard'>
|
||||
<ActionIcon onClick={() => copy(idx)} variant='filled' color='primary'>
|
||||
<CopyIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Table>
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { ClockIcon, ImageIcon, KeyIcon, TypeIcon, UserIcon, GlobeIcon } from 'components/icons';
|
||||
import React, { Dispatch, SetStateAction, useReducer, useState } from 'react';
|
||||
|
||||
export type UploadOptionsState = {
|
||||
expires: string;
|
||||
password: string;
|
||||
maxViews: number;
|
||||
compression: string;
|
||||
zeroWidth: boolean;
|
||||
embedded: boolean;
|
||||
format: string;
|
||||
originalName: boolean;
|
||||
overrideDomain: string;
|
||||
};
|
||||
|
||||
const DEFAULT_OD_DESC = 'Override the default domain(s). Type in a URL, e.g https://example.com';
|
||||
|
||||
export function OptionsModal({
|
||||
opened,
|
||||
setOpened,
|
||||
state,
|
||||
setState,
|
||||
reset,
|
||||
}: {
|
||||
opened: boolean;
|
||||
setOpened: Dispatch<SetStateAction<boolean>>;
|
||||
state: UploadOptionsState;
|
||||
setState: Dispatch<SetStateAction<any>>;
|
||||
reset: () => void;
|
||||
}) {
|
||||
const [odState, setODState] = useReducer((state, newState) => ({ ...state, ...newState }), {
|
||||
description: DEFAULT_OD_DESC,
|
||||
error: '',
|
||||
});
|
||||
|
||||
const handleOD = (e) => {
|
||||
setODState({ error: '' });
|
||||
|
||||
if (e.currentTarget.value === '') {
|
||||
setODState({ description: DEFAULT_OD_DESC, error: '' });
|
||||
setState({ overrideDomain: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(e.currentTarget.value);
|
||||
setODState({
|
||||
description: (
|
||||
<>
|
||||
{DEFAULT_OD_DESC}
|
||||
<br />
|
||||
<br />
|
||||
Using domain "<b>{url.hostname}</b>"
|
||||
</>
|
||||
),
|
||||
});
|
||||
setState({ overrideDomain: url.hostname });
|
||||
} catch (e) {
|
||||
setODState({ error: 'Invalid URL' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={<Title>Upload Options</Title>} size='lg' opened={opened} onClose={() => setOpened(false)}>
|
||||
<Stack>
|
||||
<NumberInput
|
||||
label='Max Views'
|
||||
description='The maximum number of times this file can be viewed. Leave blank for unlimited views.'
|
||||
value={state.maxViews}
|
||||
onChange={(e) => setState({ maxViews: e })}
|
||||
min={0}
|
||||
icon={<UserIcon />}
|
||||
/>
|
||||
<Select
|
||||
label='Expires'
|
||||
description='The date and time this file will expire. Leave blank for never.'
|
||||
value={state.expires}
|
||||
onChange={(e) => setState({ expires: e })}
|
||||
icon={<ClockIcon size={14} />}
|
||||
data={[
|
||||
{ value: 'never', label: 'Never' },
|
||||
{ value: '5min', label: '5 minutes' },
|
||||
{ value: '10min', label: '10 minutes' },
|
||||
{ value: '15min', label: '15 minutes' },
|
||||
{ value: '30min', label: '30 minutes' },
|
||||
{ value: '1h', label: '1 hour' },
|
||||
{ value: '2h', label: '2 hours' },
|
||||
{ value: '3h', label: '3 hours' },
|
||||
{ value: '4h', label: '4 hours' },
|
||||
{ value: '5h', label: '5 hours' },
|
||||
{ value: '6h', label: '6 hours' },
|
||||
{ value: '8h', label: '8 hours' },
|
||||
{ value: '12h', label: '12 hours' },
|
||||
{ value: '1d', label: '1 day' },
|
||||
{ value: '3d', label: '3 days' },
|
||||
{ value: '5d', label: '5 days' },
|
||||
{ value: '7d', label: '7 days' },
|
||||
{ value: '1w', label: '1 week' },
|
||||
{ value: '1.5w', label: '1.5 weeks' },
|
||||
{ value: '2w', label: '2 weeks' },
|
||||
{ value: '3w', label: '3 weeks' },
|
||||
{ value: '1m', label: '1 month' },
|
||||
{ value: '1.5m', label: '1.5 months' },
|
||||
{ value: '2m', label: '2 months' },
|
||||
{ value: '3m', label: '3 months' },
|
||||
{ value: '6m', label: '6 months' },
|
||||
{ value: '8m', label: '8 months' },
|
||||
{ value: '1y', label: '1 year' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label='Compression'
|
||||
description='The compression level to use when uploading this file. Leave blank for default.'
|
||||
value={state.compression}
|
||||
onChange={(e) => setState({ compression: e })}
|
||||
icon={<ImageIcon />}
|
||||
data={[
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: '25', label: 'Low (25%)' },
|
||||
{ value: '50', label: 'Medium (50%)' },
|
||||
{ value: '75', label: 'High (75%)' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label='Format'
|
||||
description="The file name format to use when uploading this file. Leave blank for the server's default."
|
||||
value={state.format}
|
||||
onChange={(e) => setState({ format: e })}
|
||||
icon={<TypeIcon />}
|
||||
data={[
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'RANDOM', label: 'Random' },
|
||||
{ value: 'NAME', label: 'Original Name' },
|
||||
{ value: 'DATE', label: 'Date (format configured by server)' },
|
||||
{ value: 'UUID', label: 'UUID' },
|
||||
]}
|
||||
/>
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
description='The password required to view this file. Leave blank for no password.'
|
||||
value={state.password}
|
||||
onChange={(e) => setState({ password: e.currentTarget.value })}
|
||||
icon={<KeyIcon />}
|
||||
/>
|
||||
<TextInput
|
||||
label='Override Domain'
|
||||
onChange={handleOD}
|
||||
icon={<GlobeIcon />}
|
||||
description={odState.description}
|
||||
error={odState.error}
|
||||
/>
|
||||
<Group>
|
||||
<Switch
|
||||
label='Zero Width'
|
||||
description='Whether or not to use zero width characters for the file name.'
|
||||
checked={state.zeroWidth}
|
||||
onChange={(e) => setState({ zeroWidth: e.currentTarget.checked })}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label='Embedded'
|
||||
description='Whether or not to embed with OG tags for this file.'
|
||||
checked={state.embedded}
|
||||
onChange={(e) => setState({ embedded: e.currentTarget.checked })}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label='Keep Original Name'
|
||||
description='Whether or not to show the original name when downloading this specific file. This will not change the name format in the URL.'
|
||||
checked={state.originalName}
|
||||
onChange={(e) => setState({ originalName: e.currentTarget.checked })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Button onClick={() => reset()} color='red'>
|
||||
Reset Options
|
||||
</Button>
|
||||
<Button onClick={() => setOpened(false)}>Close</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function useUploadOptions(): [UploadOptionsState, Dispatch<SetStateAction<boolean>>, any] {
|
||||
const [state, setState] = useReducer((state, newState) => ({ ...state, ...newState }), {
|
||||
expires: 'never',
|
||||
password: '',
|
||||
maxViews: 0,
|
||||
compression: 'none',
|
||||
zeroWidth: false,
|
||||
embedded: false,
|
||||
format: 'default',
|
||||
originalName: false,
|
||||
overrideDomain: '',
|
||||
} as UploadOptionsState);
|
||||
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setState({
|
||||
expires: 'never',
|
||||
password: '',
|
||||
maxViews: 0,
|
||||
compression: 'none',
|
||||
zeroWidth: false,
|
||||
embedded: false,
|
||||
format: 'default',
|
||||
originalName: false,
|
||||
overrideDomain: '',
|
||||
});
|
||||
};
|
||||
|
||||
return [
|
||||
state,
|
||||
setOpened,
|
||||
<OptionsModal
|
||||
state={state}
|
||||
setState={setState}
|
||||
reset={reset}
|
||||
opened={opened}
|
||||
setOpened={setOpened}
|
||||
key={1}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { Button, Group, NumberInput, PasswordInput, Select, Tabs, Title, Tooltip } from '@mantine/core';
|
||||
import { Prism } from '@mantine/prism';
|
||||
import { Language } from 'prism-react-renderer';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import CodeInput from 'components/CodeInput';
|
||||
import { ClockIcon, ImageIcon, TypeIcon, UploadIcon } from 'components/icons';
|
||||
import Link from 'components/Link';
|
||||
import exts from 'lib/exts';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
export default function Upload() {
|
||||
const user = useRecoilValue(userSelector);
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
const [lang, setLang] = useState('txt');
|
||||
const [password, setPassword] = useState('');
|
||||
const [expires, setExpires] = useState('never');
|
||||
const [maxViews, setMaxViews] = useState<number>(undefined);
|
||||
|
||||
const handleUpload = async () => {
|
||||
const file = new File([value], 'text.' + lang);
|
||||
|
||||
const expires_at =
|
||||
expires === 'never'
|
||||
? null
|
||||
: new Date(
|
||||
{
|
||||
'5min': Date.now() + 5 * 60 * 1000,
|
||||
'10min': Date.now() + 10 * 60 * 1000,
|
||||
'15min': Date.now() + 15 * 60 * 1000,
|
||||
'30min': Date.now() + 30 * 60 * 1000,
|
||||
'1h': Date.now() + 60 * 60 * 1000,
|
||||
'2h': Date.now() + 2 * 60 * 60 * 1000,
|
||||
'3h': Date.now() + 3 * 60 * 60 * 1000,
|
||||
'4h': Date.now() + 4 * 60 * 60 * 1000,
|
||||
'5h': Date.now() + 5 * 60 * 60 * 1000,
|
||||
'6h': Date.now() + 6 * 60 * 60 * 1000,
|
||||
'8h': Date.now() + 8 * 60 * 60 * 1000,
|
||||
'12h': Date.now() + 12 * 60 * 60 * 1000,
|
||||
'1d': Date.now() + 24 * 60 * 60 * 1000,
|
||||
'3d': Date.now() + 3 * 24 * 60 * 60 * 1000,
|
||||
'5d': Date.now() + 5 * 24 * 60 * 60 * 1000,
|
||||
'7d': Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
'1w': Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
'1.5w': Date.now() + 1.5 * 7 * 24 * 60 * 60 * 1000,
|
||||
'2w': Date.now() + 2 * 7 * 24 * 60 * 60 * 1000,
|
||||
'3w': Date.now() + 3 * 7 * 24 * 60 * 60 * 1000,
|
||||
'1m': Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||
'1.5m': Date.now() + 1.5 * 30 * 24 * 60 * 60 * 1000,
|
||||
'2m': Date.now() + 2 * 30 * 24 * 60 * 60 * 1000,
|
||||
'3m': Date.now() + 3 * 30 * 24 * 60 * 60 * 1000,
|
||||
'6m': Date.now() + 6 * 30 * 24 * 60 * 60 * 1000,
|
||||
'8m': Date.now() + 8 * 30 * 24 * 60 * 60 * 1000,
|
||||
'1y': Date.now() + 365 * 24 * 60 * 60 * 1000,
|
||||
}[expires]
|
||||
);
|
||||
|
||||
showNotification({
|
||||
id: 'upload-text',
|
||||
title: 'Uploading...',
|
||||
message: '',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
const req = new XMLHttpRequest();
|
||||
req.addEventListener('load', (e) => {
|
||||
// @ts-ignore not sure why it thinks response doesnt exist, but it does.
|
||||
const json = JSON.parse(e.target.response);
|
||||
|
||||
if (!json.error) {
|
||||
updateNotification({
|
||||
id: 'upload-text',
|
||||
title: 'Upload Successful',
|
||||
message: (
|
||||
<>
|
||||
Copied first file to clipboard! <br />
|
||||
{json.files.map((x) => (
|
||||
<Link key={x} href={x}>
|
||||
{x}
|
||||
<br />
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
|
||||
req.open('POST', '/api/upload');
|
||||
req.setRequestHeader('Authorization', user.token);
|
||||
req.setRequestHeader('UploadText', 'true');
|
||||
|
||||
expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expires_at.toISOString());
|
||||
password !== '' && req.setRequestHeader('Password', password);
|
||||
maxViews && maxViews !== 0 && req.setRequestHeader('Max-Views', String(maxViews));
|
||||
|
||||
req.send(body);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title mb='md'>Upload Text</Title>
|
||||
|
||||
<Tabs defaultValue='text' variant='pills'>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value='text' icon={<TypeIcon />}>
|
||||
Text
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value='preview' icon={<ImageIcon />}>
|
||||
Preview
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel mt='sm' value='text'>
|
||||
<CodeInput value={value} onChange={(e) => setValue(e.target.value)} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel mt='sm' value='preview'>
|
||||
<Prism
|
||||
sx={(t) => ({ height: '80vh', backgroundColor: t.colors.dark[8] })}
|
||||
withLineNumbers
|
||||
language={lang as Language}
|
||||
>
|
||||
{value}
|
||||
</Prism>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Group position='right' mt='md'>
|
||||
<Select
|
||||
value={lang}
|
||||
onChange={setLang}
|
||||
dropdownPosition='top'
|
||||
data={Object.keys(exts).map((x) => ({ value: x, label: exts[x] }))}
|
||||
icon={<TypeIcon />}
|
||||
searchable
|
||||
/>
|
||||
<Tooltip label='After the file reaches this amount of views, it will be deleted automatically. Leave blank for no limit.'>
|
||||
<NumberInput placeholder='Max Views' min={0} value={maxViews} onChange={(x) => setMaxViews(x)} />
|
||||
</Tooltip>
|
||||
<Tooltip label='Add a password to your files (optional, leave blank for none)'>
|
||||
<PasswordInput
|
||||
style={{ width: '252px' }}
|
||||
placeholder='Password'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label='Set an expiration date for your files (optional, defaults to never)'>
|
||||
<Select
|
||||
value={expires}
|
||||
onChange={(e) => setExpires(e)}
|
||||
icon={<ClockIcon size={14} />}
|
||||
data={[
|
||||
{ value: 'never', label: 'Never' },
|
||||
{ value: '5min', label: '5 minutes' },
|
||||
{ value: '10min', label: '10 minutes' },
|
||||
{ value: '15min', label: '15 minutes' },
|
||||
{ value: '30min', label: '30 minutes' },
|
||||
{ value: '1h', label: '1 hour' },
|
||||
{ value: '2h', label: '2 hours' },
|
||||
{ value: '3h', label: '3 hours' },
|
||||
{ value: '4h', label: '4 hours' },
|
||||
{ value: '5h', label: '5 hours' },
|
||||
{ value: '6h', label: '6 hours' },
|
||||
{ value: '8h', label: '8 hours' },
|
||||
{ value: '12h', label: '12 hours' },
|
||||
{ value: '1d', label: '1 day' },
|
||||
{ value: '3d', label: '3 days' },
|
||||
{ value: '5d', label: '5 days' },
|
||||
{ value: '7d', label: '7 days' },
|
||||
{ value: '1w', label: '1 week' },
|
||||
{ value: '1.5w', label: '1.5 weeks' },
|
||||
{ value: '2w', label: '2 weeks' },
|
||||
{ value: '3w', label: '3 weeks' },
|
||||
{ value: '1m', label: '1 month' },
|
||||
{ value: '1.5m', label: '1.5 months' },
|
||||
{ value: '2m', label: '2 months' },
|
||||
{ value: '3m', label: '3 months' },
|
||||
{ value: '6m', label: '6 months' },
|
||||
{ value: '8m', label: '8 months' },
|
||||
{ value: '1y', label: '1 year' },
|
||||
]}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button
|
||||
leftIcon={<UploadIcon />}
|
||||
onClick={handleUpload}
|
||||
disabled={value.trim().length === 0 ? true : false}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,11 +14,18 @@ export default function URLCard({ url }: { url: URLResponse }) {
|
||||
|
||||
const copyURL = (u) => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
|
||||
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: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const deleteURL = async (u) => {
|
||||
@@ -51,9 +58,9 @@ export default function URLCard({ url }: { url: URLResponse }) {
|
||||
<Group position='left'>
|
||||
<Stack spacing={0}>
|
||||
<Title>{url.vanity ?? url.id}</Title>
|
||||
<Tooltip label={new Date(url.created_at).toLocaleString()}>
|
||||
<Tooltip label={new Date(url.createdAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>Created: {relativeTime(new Date(url.created_at))}</MutedText>
|
||||
<MutedText size='sm'>Created: {relativeTime(new Date(url.createdAt))}</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{url.vanity && <MutedText size='sm'>ID: {url.id}</MutedText>}
|
||||
@@ -70,7 +77,7 @@ export default function URLCard({ url }: { url: URLResponse }) {
|
||||
</MutedText>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
<Stack>
|
||||
<ActionIcon href={url.url} component='a' target='_blank'>
|
||||
<ExternalLinkIcon />
|
||||
</ActionIcon>
|
||||
@@ -80,7 +87,7 @@ export default function URLCard({ url }: { url: URLResponse }) {
|
||||
<ActionIcon aria-label='delete' onClick={() => deleteURL(url)}>
|
||||
<TrashIcon />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
TextInput,
|
||||
Title,
|
||||
Card,
|
||||
Center,
|
||||
NumberInput,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { CrossIcon, LinkIcon, PlusIcon } from 'components/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useURLs } from 'lib/queries/url';
|
||||
import URLCard from './URLCard';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useURLs } from 'lib/queries/url';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import URLCard from './URLCard';
|
||||
|
||||
export default function Urls() {
|
||||
const user = useRecoilValue(userSelector);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Modal, TextInput, Switch, Group, Button, Title } from '@mantine/core';
|
||||
import { Button, Group, Modal, Switch, TextInput, Title } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { DeleteIcon, PlusIcon } from 'components/icons';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Modal, TextInput, Switch, Group, Button, Title } from '@mantine/core';
|
||||
import { Button, Group, Modal, Switch, TextInput, Title } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { DeleteIcon, PlusIcon } from 'components/icons';
|
||||
|
||||
@@ -21,11 +21,11 @@ export default function Users() {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
|
||||
const handleDelete = async (user, delete_images) => {
|
||||
const res = await useFetch('/api/users', 'DELETE', {
|
||||
id: user.id,
|
||||
delete_images,
|
||||
const handleDelete = async (user, delete_files) => {
|
||||
const res = await useFetch(`/api/user/${user.id}`, 'DELETE', {
|
||||
delete_files,
|
||||
});
|
||||
|
||||
if (res.error) {
|
||||
showNotification({
|
||||
title: 'Failed to delete user',
|
||||
@@ -52,7 +52,7 @@ export default function Users() {
|
||||
labels: { confirm: 'Yes', cancel: 'No' },
|
||||
onConfirm: () => {
|
||||
modals.openConfirmModal({
|
||||
title: `Delete ${user.username}'s images?`,
|
||||
title: `Delete ${user.username}'s files?`,
|
||||
labels: { confirm: 'Yes', cancel: 'No' },
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
@@ -113,7 +113,7 @@ export default function Users() {
|
||||
<MutedText size='sm'>Administrator: {user.administrator ? 'yes' : 'no'}</MutedText>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
<Stack>
|
||||
{user.administrator && !self.superAdmin ? null : (
|
||||
<>
|
||||
<ActionIcon
|
||||
@@ -130,7 +130,7 @@ export default function Users() {
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Alert } from '@mantine/core';
|
||||
import katex, { ParseError } from 'katex';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
const sanitize = (str: string) => {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
};
|
||||
|
||||
export default function KaTeX({ code, ...props }) {
|
||||
const [rendered, setRendered] = useState('');
|
||||
const [error, setError] = useState<JSX.Element>();
|
||||
|
||||
const renderError = (error: ParseError | TypeError) => {
|
||||
return (
|
||||
<Alert title={error.name} color='red'>
|
||||
{sanitize(error.message)}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const html = katex.renderToString(code, {
|
||||
displayMode: true,
|
||||
throwOnError: true,
|
||||
errorColor: '#f44336',
|
||||
});
|
||||
|
||||
setRendered(html);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError(renderError(e));
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}, [rendered, error, code]);
|
||||
|
||||
if (error) return error;
|
||||
|
||||
return (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: rendered,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Code } from '@mantine/core';
|
||||
import { Prism } from '@mantine/prism';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
export default function Markdown({ code, ...props }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return !inline && match ? (
|
||||
// @ts-ignore
|
||||
<Prism language={match[1]} {...props}>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</Prism>
|
||||
) : (
|
||||
<Code {...props}>{children}</Code>
|
||||
);
|
||||
},
|
||||
img({ node, ...props }) {
|
||||
return <img {...props} style={{ maxWidth: '100%' }} />;
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{code}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Prism } from '@mantine/prism';
|
||||
import { Prism as PrismLib } from 'prism-react-renderer';
|
||||
import exts, { extToPrismComponent } from 'lib/exts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
(typeof window === 'undefined' ? global : window).Prism = PrismLib;
|
||||
|
||||
export default function PrismCode({ code, ext, ...props }) {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const component = extToPrismComponent[ext];
|
||||
if (component && ext !== 'txt') await import(`prismjs/components/prism-${component}`);
|
||||
})();
|
||||
}, [ext]);
|
||||
|
||||
return (
|
||||
<Prism
|
||||
sx={(t) => ({ height: '100vh', backgroundColor: t.colors.dark[8] })}
|
||||
withLineNumbers
|
||||
language={exts[ext]?.toLowerCase()}
|
||||
{...props}
|
||||
>
|
||||
{code}
|
||||
</Prism>
|
||||
);
|
||||
}
|
||||
+7
-3
@@ -1,7 +1,11 @@
|
||||
import { Config } from './config/Config';
|
||||
import readConfig from './config/readConfig';
|
||||
import validateConfig from './config/validateConfig';
|
||||
import { Config } from 'config/Config';
|
||||
import readConfig from 'config/readConfig';
|
||||
import validateConfig from 'config/validateConfig';
|
||||
|
||||
if (!global.config) global.config = validateConfig(readConfig());
|
||||
|
||||
export default global.config as Config;
|
||||
|
||||
declare global {
|
||||
var config: Config;
|
||||
}
|
||||
|
||||
+41
-14
@@ -1,5 +1,5 @@
|
||||
export interface ConfigCore {
|
||||
https: boolean;
|
||||
return_https: boolean;
|
||||
secret: string;
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -11,10 +11,10 @@ export interface ConfigCore {
|
||||
}
|
||||
|
||||
export interface ConfigDatasource {
|
||||
type: 'local' | 's3' | 'swift';
|
||||
type: 'local' | 's3' | 'supabase';
|
||||
local: ConfigLocalDatasource;
|
||||
s3?: ConfigS3Datasource;
|
||||
swift?: ConfigSwiftDatasource;
|
||||
supabase?: ConfigSupabaseDatasource;
|
||||
}
|
||||
|
||||
export interface ConfigLocalDatasource {
|
||||
@@ -32,23 +32,21 @@ export interface ConfigS3Datasource {
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export interface ConfigSwiftDatasource {
|
||||
container: string;
|
||||
auth_endpoint: string;
|
||||
username: string;
|
||||
password: string;
|
||||
project_id: string;
|
||||
domain_id?: string;
|
||||
region_id?: string;
|
||||
export interface ConfigSupabaseDatasource {
|
||||
url: string;
|
||||
key: string;
|
||||
bucket: string;
|
||||
}
|
||||
|
||||
export interface ConfigUploader {
|
||||
default_format: string;
|
||||
route: string;
|
||||
length: number;
|
||||
admin_limit: number;
|
||||
user_limit: number;
|
||||
disabled_extensions: string[];
|
||||
format_date: string;
|
||||
default_expiration: string;
|
||||
}
|
||||
|
||||
export interface ConfigUrls {
|
||||
@@ -76,15 +74,18 @@ export interface ConfigWebsiteExternalLinks {
|
||||
}
|
||||
|
||||
export interface ConfigDiscord {
|
||||
url: string;
|
||||
username: string;
|
||||
avatar_url: string;
|
||||
url?: string;
|
||||
username?: string;
|
||||
avatar_url?: string;
|
||||
|
||||
upload: ConfigDiscordContent;
|
||||
shorten: ConfigDiscordContent;
|
||||
}
|
||||
|
||||
export interface ConfigDiscordContent {
|
||||
url?: string;
|
||||
username?: string;
|
||||
avatar_url?: string;
|
||||
content: string;
|
||||
embed: ConfigDiscordEmbed;
|
||||
}
|
||||
@@ -101,8 +102,15 @@ export interface ConfigDiscordEmbed {
|
||||
|
||||
export interface ConfigFeatures {
|
||||
invites: boolean;
|
||||
invites_length: number;
|
||||
|
||||
oauth_registration: boolean;
|
||||
oauth_login_only: boolean;
|
||||
user_registration: boolean;
|
||||
|
||||
headless: boolean;
|
||||
|
||||
default_avatar: string;
|
||||
}
|
||||
|
||||
export interface ConfigOAuth {
|
||||
@@ -121,6 +129,22 @@ export interface ConfigChunks {
|
||||
chunks_size: number;
|
||||
}
|
||||
|
||||
export interface ConfigMfa {
|
||||
totp_enabled: boolean;
|
||||
totp_issuer: string;
|
||||
}
|
||||
|
||||
export interface ConfigExif {
|
||||
enabled: boolean;
|
||||
remove_gps: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigSsl {
|
||||
allow_http1: boolean;
|
||||
key: string;
|
||||
cert: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
core: ConfigCore;
|
||||
uploader: ConfigUploader;
|
||||
@@ -132,4 +156,7 @@ export interface Config {
|
||||
oauth: ConfigOAuth;
|
||||
features: ConfigFeatures;
|
||||
chunks: ConfigChunks;
|
||||
mfa: ConfigMfa;
|
||||
exif: ConfigExif;
|
||||
ssl: ConfigSsl;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { parse } from 'dotenv';
|
||||
import { expand } from 'dotenv-expand';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { humanToBytes } from '../utils/bytes';
|
||||
import { resolve } from 'path';
|
||||
import Logger from 'lib/logger';
|
||||
import { humanToBytes } from 'utils/bytes';
|
||||
|
||||
export type ValueType = 'string' | 'number' | 'boolean' | 'array' | 'json-array' | 'human-to-byte';
|
||||
export type ValueType = 'string' | 'number' | 'boolean' | 'array' | 'json-array' | 'human-to-byte' | 'path';
|
||||
|
||||
function isObject(value: any): value is Record<string, any> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
@@ -36,6 +38,9 @@ function map(env: string, type: ValueType, path: string) {
|
||||
}
|
||||
|
||||
export default function readConfig() {
|
||||
const logger = Logger.get('config');
|
||||
|
||||
logger.debug('attemping to read .env.local/.env or environment variables');
|
||||
if (existsSync('.env.local')) {
|
||||
const contents = readFileSync('.env.local');
|
||||
|
||||
@@ -51,7 +56,7 @@ export default function readConfig() {
|
||||
}
|
||||
|
||||
const maps = [
|
||||
map('CORE_HTTPS', 'boolean', 'core.https'),
|
||||
map('CORE_RETURN_HTTPS', 'boolean', 'core.return_https'),
|
||||
map('CORE_SECRET', 'string', 'core.secret'),
|
||||
map('CORE_HOST', 'string', 'core.host'),
|
||||
map('CORE_PORT', 'number', 'core.port'),
|
||||
@@ -73,20 +78,18 @@ export default function readConfig() {
|
||||
map('DATASOURCE_S3_REGION', 'string', 'datasource.s3.region'),
|
||||
map('DATASOURCE_S3_USE_SSL', 'boolean', 'datasource.s3.use_ssl'),
|
||||
|
||||
map('DATASOURCE_SWIFT_USERNAME', 'string', 'datasource.swift.username'),
|
||||
map('DATASOURCE_SWIFT_PASSWORD', 'string', 'datasource.swift.password'),
|
||||
map('DATASOURCE_SWIFT_AUTH_ENDPOINT', 'string', 'datasource.swift.auth_endpoint'),
|
||||
map('DATASOURCE_SWIFT_CONTAINER', 'string', 'datasource.swift.container'),
|
||||
map('DATASOURCE_SWIFT_PROJECT_ID', 'string', 'datasource.swift.project_id'),
|
||||
map('DATASOURCE_SWIFT_DOMAIN_ID', 'string', 'datasource.swift.domain_id'),
|
||||
map('DATASOURCE_SWIFT_REGION_ID', 'string', 'datasource.swift.region_id'),
|
||||
map('DATASOURCE_SUPABASE_URL', 'string', 'datasource.supabase.url'),
|
||||
map('DATASOURCE_SUPABASE_KEY', 'string', 'datasource.supabase.key'),
|
||||
map('DATASOURCE_SUPABASE_BUCKET', 'string', 'datasource.supabase.bucket'),
|
||||
|
||||
map('UPLOADER_DEFAULT_FORMAT', 'string', 'uploader.default_format'),
|
||||
map('UPLOADER_ROUTE', 'string', 'uploader.route'),
|
||||
map('UPLOADER_LENGTH', 'number', 'uploader.length'),
|
||||
map('UPLOADER_ADMIN_LIMIT', 'human-to-byte', 'uploader.admin_limit'),
|
||||
map('UPLOADER_USER_LIMIT', 'human-to-byte', 'uploader.user_limit'),
|
||||
map('UPLOADER_DISABLED_EXTENSIONS', 'array', 'uploader.disabled_extensions'),
|
||||
map('UPLOADER_FORMAT_DATE', 'string', 'uploader.format_date'),
|
||||
map('UPLOADER_DEFAULT_EXPIRATION', 'string', 'uploader.default_expiration'),
|
||||
|
||||
map('URLS_ROUTE', 'string', 'urls.route'),
|
||||
map('URLS_LENGTH', 'number', 'urls.length'),
|
||||
@@ -104,6 +107,9 @@ export default function readConfig() {
|
||||
map('DISCORD_USERNAME', 'string', 'discord.username'),
|
||||
map('DISCORD_AVATAR_URL', 'string', 'discord.avatar_url'),
|
||||
|
||||
map('DISCORD_UPLOAD_URL', 'string', 'discord.upload.url'),
|
||||
map('DISCORD_UPLOAD_USERNAME', 'string', 'discord.upload.username'),
|
||||
map('DISCORD_UPLOAD_AVATAR_URL', 'string', 'discord.upload.avatar_url'),
|
||||
map('DISCORD_UPLOAD_CONTENT', 'string', 'discord.upload.content'),
|
||||
map('DISCORD_UPLOAD_EMBED_TITLE', 'string', 'discord.upload.embed.title'),
|
||||
map('DISCORD_UPLOAD_EMBED_DESCRIPTION', 'string', 'discord.upload.embed.description'),
|
||||
@@ -113,6 +119,9 @@ export default function readConfig() {
|
||||
map('DISCORD_UPLOAD_EMBED_THUMBNAIL', 'boolean', 'discord.upload.embed.thumbnail'),
|
||||
map('DISCORD_UPLOAD_EMBED_TIMESTAMP', 'boolean', 'discord.upload.embed.timestamp'),
|
||||
|
||||
map('DISCORD_SHORTEN_URL', 'string', 'discord.shorten.url'),
|
||||
map('DISCORD_SHORTEN_USERNAME', 'string', 'discord.shorten.username'),
|
||||
map('DISCORD_SHORTEN_AVATAR_URL', 'string', 'discord.shorten.avatar_url'),
|
||||
map('DISCORD_SHORTEN_CONTENT', 'string', 'discord.shorten.content'),
|
||||
map('DISCORD_SHORTEN_EMBED_TITLE', 'string', 'discord.shorten.embed.title'),
|
||||
map('DISCORD_SHORTEN_EMBED_DESCRIPTION', 'string', 'discord.shorten.embed.description'),
|
||||
@@ -132,11 +141,28 @@ export default function readConfig() {
|
||||
map('OAUTH_GOOGLE_CLIENT_SECRET', 'string', 'oauth.google_client_secret'),
|
||||
|
||||
map('FEATURES_INVITES', 'boolean', 'features.invites'),
|
||||
map('FEATURES_INVITES_LENGTH', 'number', 'features.invites_length'),
|
||||
|
||||
map('FEATURES_OAUTH_REGISTRATION', 'boolean', 'features.oauth_registration'),
|
||||
map('FEATURES_OAUTH_LOGIN_ONLY', 'boolean', 'features.oauth_login_only'),
|
||||
map('FEATURES_USER_REGISTRATION', 'boolean', 'features.user_registration'),
|
||||
|
||||
map('FEATURES_HEADLESS', 'boolean', 'features.headless'),
|
||||
|
||||
map('FEATURES_DEFAULT_AVATAR', 'path', 'features.default_avatar'),
|
||||
|
||||
map('CHUNKS_MAX_SIZE', 'human-to-byte', 'chunks.max_size'),
|
||||
map('CHUNKS_CHUNKS_SIZE', 'human-to-byte', 'chunks.chunks_size'),
|
||||
|
||||
map('MFA_TOTP_ISSUER', 'string', 'mfa.totp_issuer'),
|
||||
map('MFA_TOTP_ENABLED', 'boolean', 'mfa.totp_enabled'),
|
||||
|
||||
map('EXIF_ENABLED', 'boolean', 'exif.enabled'),
|
||||
map('EXIF_REMOVE_GPS', 'boolean', 'exif.remove_gps'),
|
||||
|
||||
map('SSL_KEY', 'path', 'ssl.key'),
|
||||
map('SSL_CERT', 'path', 'ssl.cert'),
|
||||
map('SSL_ALLOW_HTTP1', 'boolean', 'ssl.allow_http1'),
|
||||
];
|
||||
|
||||
const config = {};
|
||||
@@ -154,6 +180,10 @@ export default function readConfig() {
|
||||
break;
|
||||
case 'number':
|
||||
parsed = Number(value);
|
||||
if (isNaN(parsed)) {
|
||||
parsed = undefined;
|
||||
logger.debug(`Failed to parse number ${map.env}=${value}`);
|
||||
}
|
||||
break;
|
||||
case 'boolean':
|
||||
parsed = value === 'true';
|
||||
@@ -162,11 +192,17 @@ export default function readConfig() {
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch (e) {
|
||||
parsed = [];
|
||||
logger.debug(`Failed to parse JSON array ${map.env}=${value}`);
|
||||
}
|
||||
break;
|
||||
case 'human-to-byte':
|
||||
parsed = humanToBytes(value) ?? undefined;
|
||||
if (!parsed) logger.debug(`Unable to parse ${map.env}=${value}`);
|
||||
|
||||
break;
|
||||
case 'path':
|
||||
parsed = resolve(value);
|
||||
if (!existsSync(parsed)) logger.debug(`Unable to find ${map.env}=${value} (path does not exist)`);
|
||||
break;
|
||||
default:
|
||||
parsed = value;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Config } from 'lib/config/Config';
|
||||
import { s } from '@sapphire/shapeshift';
|
||||
import type { Config } from './Config';
|
||||
import { inspect } from 'util';
|
||||
import Logger from '../logger';
|
||||
import { humanToBytes } from '../utils/bytes';
|
||||
import Logger from 'lib/logger';
|
||||
import { humanToBytes } from 'utils/bytes';
|
||||
|
||||
const discord_content = s
|
||||
.object({
|
||||
url: s.string.nullish.default(null),
|
||||
username: s.string.nullish.default(null),
|
||||
avatar_url: s.string.nullish.default(null),
|
||||
content: s.string.nullish.default(null),
|
||||
embed: s
|
||||
.object({
|
||||
@@ -23,7 +26,7 @@ const discord_content = s
|
||||
|
||||
const validator = s.object({
|
||||
core: s.object({
|
||||
https: s.boolean.default(false),
|
||||
return_https: s.boolean.default(false),
|
||||
secret: s.string.lengthGreaterThanOrEqual(8),
|
||||
host: s.string.default('0.0.0.0'),
|
||||
port: s.number.default(3000),
|
||||
@@ -34,7 +37,7 @@ const validator = s.object({
|
||||
}),
|
||||
datasource: s
|
||||
.object({
|
||||
type: s.enum('local', 's3', 'swift').default('local'),
|
||||
type: s.enum('local', 's3', 'supabase').default('local'),
|
||||
local: s
|
||||
.object({
|
||||
directory: s.string.default('./uploads'),
|
||||
@@ -52,14 +55,10 @@ const validator = s.object({
|
||||
region: s.string.default('us-east-1'),
|
||||
use_ssl: s.boolean.default(false),
|
||||
}).optional,
|
||||
swift: s.object({
|
||||
username: s.string,
|
||||
password: s.string,
|
||||
auth_endpoint: s.string,
|
||||
container: s.string,
|
||||
project_id: s.string,
|
||||
domain_id: s.string.default('default'),
|
||||
region_id: s.string.nullable,
|
||||
supabase: s.object({
|
||||
url: s.string,
|
||||
key: s.string,
|
||||
bucket: s.string,
|
||||
}).optional,
|
||||
})
|
||||
.default({
|
||||
@@ -71,12 +70,10 @@ const validator = s.object({
|
||||
region: 'us-east-1',
|
||||
force_s3_path: false,
|
||||
},
|
||||
swift: {
|
||||
domain_id: 'default',
|
||||
},
|
||||
}),
|
||||
uploader: s
|
||||
.object({
|
||||
default_format: s.string.default('RANDOM'),
|
||||
route: s.string.default('/u'),
|
||||
embed_route: s.string.default('/a'),
|
||||
length: s.number.default(6),
|
||||
@@ -84,8 +81,10 @@ const validator = s.object({
|
||||
user_limit: s.number.default(humanToBytes('100MB')),
|
||||
disabled_extensions: s.string.array.default([]),
|
||||
format_date: s.string.default('YYYY-MM-DD_HH:mm:ss'),
|
||||
default_expiration: s.string.optional.default(null),
|
||||
})
|
||||
.default({
|
||||
default_format: 'RANDOM',
|
||||
route: '/u',
|
||||
embed_route: '/a',
|
||||
length: 6,
|
||||
@@ -93,6 +92,7 @@ const validator = s.object({
|
||||
user_limit: humanToBytes('100MB'),
|
||||
disabled_extensions: [],
|
||||
format_date: 'YYYY-MM-DD_HH:mm:ss',
|
||||
default_expiration: null,
|
||||
}),
|
||||
urls: s
|
||||
.object({
|
||||
@@ -144,11 +144,9 @@ const validator = s.object({
|
||||
}),
|
||||
discord: s
|
||||
.object({
|
||||
url: s.string,
|
||||
username: s.string.default('Zipline'),
|
||||
avatar_url: s.string.default(
|
||||
'https://raw.githubusercontent.com/diced/zipline/9b60147e112ec5b70170500b85c75ea621f41d03/public/zipline.png'
|
||||
),
|
||||
url: s.string.nullish.default(null),
|
||||
username: s.string.nullish.default(null),
|
||||
avatar_url: s.string.nullish.default(null),
|
||||
upload: discord_content,
|
||||
shorten: discord_content,
|
||||
})
|
||||
@@ -168,10 +166,22 @@ const validator = s.object({
|
||||
features: s
|
||||
.object({
|
||||
invites: s.boolean.default(false),
|
||||
invites_length: s.number.default(6),
|
||||
oauth_registration: s.boolean.default(false),
|
||||
oauth_login_only: s.boolean.default(false),
|
||||
user_registration: s.boolean.default(false),
|
||||
headless: s.boolean.default(false),
|
||||
default_avatar: s.string.nullable.default(null),
|
||||
})
|
||||
.default({ invites: false, oauth_registration: false, user_registration: false }),
|
||||
.default({
|
||||
invites: false,
|
||||
invites_length: 6,
|
||||
oauth_registration: false,
|
||||
oauth_login_only: false,
|
||||
user_registration: false,
|
||||
headless: false,
|
||||
default_avatar: null,
|
||||
}),
|
||||
chunks: s
|
||||
.object({
|
||||
max_size: s.number.default(humanToBytes('90MB')),
|
||||
@@ -181,11 +191,40 @@ const validator = s.object({
|
||||
max_size: humanToBytes('90MB'),
|
||||
chunks_size: humanToBytes('20MB'),
|
||||
}),
|
||||
mfa: s
|
||||
.object({
|
||||
totp_issuer: s.string.default('Zipline'),
|
||||
totp_enabled: s.boolean.default(false),
|
||||
})
|
||||
.default({
|
||||
totp_issuer: 'Zipline',
|
||||
totp_enabled: false,
|
||||
}),
|
||||
exif: s
|
||||
.object({
|
||||
enabled: s.boolean.default(false),
|
||||
remove_gps: s.boolean.default(false),
|
||||
})
|
||||
.default({
|
||||
enabled: false,
|
||||
remove_gps: false,
|
||||
}),
|
||||
ssl: s
|
||||
.object({
|
||||
key: s.string,
|
||||
cert: s.string,
|
||||
allow_http1: s.boolean.default(false),
|
||||
})
|
||||
.optional.nullish.default(null),
|
||||
});
|
||||
|
||||
export default function validate(config): Config {
|
||||
const logger = Logger.get('config');
|
||||
|
||||
try {
|
||||
logger.debug(`Attemping to validate ${JSON.stringify(config)}`);
|
||||
const validated = validator.parse(config);
|
||||
logger.debug(`Recieved config: ${JSON.stringify(validated)}`);
|
||||
switch (validated.datasource.type) {
|
||||
case 's3': {
|
||||
const errors = [];
|
||||
@@ -198,31 +237,48 @@ export default function validate(config): Config {
|
||||
if (errors.length) throw { errors };
|
||||
break;
|
||||
}
|
||||
case 'swift': {
|
||||
case 'supabase': {
|
||||
const errors = [];
|
||||
if (!validated.datasource.swift.container)
|
||||
errors.push('datasource.swift.container is a required field');
|
||||
if (!validated.datasource.swift.project_id)
|
||||
errors.push('datasource.swift.project_id is a required field');
|
||||
if (!validated.datasource.swift.auth_endpoint)
|
||||
errors.push('datasource.swift.auth_endpoint is a required field');
|
||||
if (!validated.datasource.swift.password)
|
||||
errors.push('datasource.swift.password is a required field');
|
||||
if (!validated.datasource.swift.username)
|
||||
errors.push('datasource.swift.username is a required field');
|
||||
|
||||
if (!validated.datasource.supabase.key) errors.push('datasource.supabase.key is a required field');
|
||||
if (!validated.datasource.supabase.url) errors.push('datasource.supabase.url is a required field');
|
||||
if (!validated.datasource.supabase.bucket)
|
||||
errors.push('datasource.supabase.bucket is a required field');
|
||||
if (errors.length) throw { errors };
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const reserved = ['/view', '/dashboard', '/code', '/folder', '/api', '/auth'];
|
||||
if (reserved.some((r) => validated.uploader.route.startsWith(r))) {
|
||||
throw {
|
||||
errors: [`The uploader route cannot be ${validated.uploader.route}, this is a reserved route.`],
|
||||
show: true,
|
||||
};
|
||||
} else if (reserved.some((r) => validated.urls.route.startsWith(r))) {
|
||||
throw {
|
||||
errors: [`The urls route cannot be ${validated.urls.route}, this is a reserved route.`],
|
||||
show: true,
|
||||
};
|
||||
}
|
||||
|
||||
return validated as unknown as Config;
|
||||
} catch (e) {
|
||||
if (process.env.ZIPLINE_DOCKER_BUILD) return null;
|
||||
|
||||
if (e.show) {
|
||||
Logger.get('config').error('Config is invalid, see below:').error(e.errors.join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.debug(`config error: ${inspect(e, { depth: Infinity })}`);
|
||||
|
||||
e.stack = '';
|
||||
|
||||
Logger.get('config').error('Config is invalid, see below:');
|
||||
Logger.get('config').error(inspect(e, { depth: Infinity, colors: true }));
|
||||
Logger.get('config')
|
||||
.error('Config is invalid, see below:')
|
||||
.error(inspect(e, { depth: Infinity, colors: true }));
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
+12
-6
@@ -1,20 +1,22 @@
|
||||
import config from './config';
|
||||
import { Swift, Local, S3, Datasource } from './datasources';
|
||||
import { Datasource, Local, S3, Supabase } from './datasources';
|
||||
import Logger from './logger';
|
||||
|
||||
const logger = Logger.get('datasource');
|
||||
|
||||
if (!global.datasource) {
|
||||
switch (config.datasource.type) {
|
||||
case 's3':
|
||||
global.datasource = new S3(config.datasource.s3);
|
||||
Logger.get('datasource').info(`using S3(${config.datasource.s3.bucket}) datasource`);
|
||||
logger.info(`using S3(${config.datasource.s3.bucket}) datasource`);
|
||||
break;
|
||||
case 'local':
|
||||
global.datasource = new Local(config.datasource.local.directory);
|
||||
Logger.get('datasource').info(`using Local(${config.datasource.local.directory}) datasource`);
|
||||
logger.info(`using Local(${config.datasource.local.directory}) datasource`);
|
||||
break;
|
||||
case 'swift':
|
||||
global.datasource = new Swift(config.datasource.swift);
|
||||
Logger.get('datasource').info(`using Swift(${config.datasource.swift.container}) datasource`);
|
||||
case 'supabase':
|
||||
global.datasource = new Supabase(config.datasource.supabase);
|
||||
logger.info(`using Supabase(${config.datasource.supabase.bucket}) datasource`);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid datasource type');
|
||||
@@ -22,3 +24,7 @@ if (!global.datasource) {
|
||||
}
|
||||
|
||||
export default global.datasource as Datasource;
|
||||
|
||||
declare global {
|
||||
var datasource: Datasource;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export abstract class Datasource {
|
||||
|
||||
public abstract save(file: string, data: Buffer): Promise<void>;
|
||||
public abstract delete(file: string): Promise<void>;
|
||||
public abstract clear(): Promise<void>;
|
||||
public abstract size(file: string): Promise<number>;
|
||||
public abstract get(file: string): Readable | Promise<Readable>;
|
||||
public abstract fullSize(): Promise<number>;
|
||||
|
||||
@@ -18,6 +18,13 @@ export class Local extends Datasource {
|
||||
await rm(join(process.cwd(), this.path, file));
|
||||
}
|
||||
|
||||
public async clear(): Promise<void> {
|
||||
const files = await readdir(join(process.cwd(), this.path));
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
await rm(join(process.cwd(), this.path, files[i]));
|
||||
}
|
||||
}
|
||||
public get(file: string): ReadStream {
|
||||
const full = join(process.cwd(), this.path, file);
|
||||
if (!existsSync(full)) return null;
|
||||
|
||||
@@ -28,6 +28,18 @@ export class S3 extends Datasource {
|
||||
await this.s3.removeObject(this.config.bucket, file);
|
||||
}
|
||||
|
||||
public async clear(): Promise<void> {
|
||||
const objects = this.s3.listObjectsV2(this.config.bucket, '', true);
|
||||
const files = [];
|
||||
|
||||
objects.on('data', (item) => files.push(item.name));
|
||||
objects.on('end', async () => {
|
||||
this.s3.removeObjects(this.config.bucket, files, (err) => {
|
||||
if (err) throw err;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public get(file: string): Promise<Readable> {
|
||||
return new Promise((res, rej) => {
|
||||
this.s3.getObject(this.config.bucket, file, (err, stream) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user