mirror of
https://github.com/diced/zipline.git
synced 2025-12-25 12:25:08 -08:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b60147e11 | ||
|
|
acd0cabdff | ||
|
|
d41f6058f7 | ||
|
|
8f835eec4e | ||
|
|
ecab525ffd | ||
|
|
7c887e8ec1 | ||
|
|
f3a23a528b | ||
|
|
cdcb31130b | ||
|
|
3ea24ddf0c | ||
|
|
12baadd563 | ||
|
|
f5ae36d4e7 | ||
|
|
04ca738fb1 | ||
|
|
95e09e51e1 | ||
|
|
2f0af385c7 | ||
|
|
786e6d5799 | ||
|
|
61c5df750a | ||
|
|
eb30afcb83 | ||
|
|
cdf0f6e96c | ||
|
|
54158c5dbe | ||
|
|
56ff86db44 | ||
|
|
b7560c80aa | ||
|
|
03379943de | ||
|
|
2376fd8968 | ||
|
|
2f90193d7e | ||
|
|
964199f8a9 | ||
|
|
678ea20004 | ||
|
|
ea27fd8a45 | ||
|
|
38eef3f0ad | ||
|
|
22615e9ce9 | ||
|
|
a999abfbf8 | ||
|
|
20c1d3ef08 | ||
|
|
b06c8e4918 | ||
|
|
6edfdcefcc | ||
|
|
10b145b006 | ||
|
|
0ba9a9659d | ||
|
|
2dfa1b6b14 | ||
|
|
7a3f9f1fa4 | ||
|
|
f276fdc6a0 |
46
.env.local.example
Normal file
46
.env.local.example
Normal file
@@ -0,0 +1,46 @@
|
||||
# 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
|
||||
|
||||
CORE_HTTPS=true
|
||||
CORE_SECRET="changethis"
|
||||
CORE_HOST=0.0.0.0
|
||||
CORE_PORT=3000
|
||||
CORE_DATABASE_URL="postgres://postgres:postgres@localhost/zip10"
|
||||
CORE_LOGGER=false
|
||||
CORE_STATS_INTERVAL=1800
|
||||
|
||||
# default
|
||||
DATASOURCE_TYPE=local
|
||||
DATASOURCE_LOCAL_DIRECTORY=./uploads
|
||||
|
||||
# or you can choose to use s3
|
||||
DATASOURCE_TYPE=s3
|
||||
DATASOURCE_S3_ACCESS_KEY_ID=key
|
||||
DATASOURCE_S3_SECRET_ACCESS_KEY=secret
|
||||
DATASOURCE_S3_BUCKET=bucket
|
||||
DATASOURCE_S3_ENDPOINT=s3.amazonaws.com
|
||||
DATASOURCE_S3_REGION=us-west-2
|
||||
DATASOURCE_S3_FORCE_S3_PATH=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
|
||||
|
||||
UPLOADER_ROUTE=/u
|
||||
UPLOADER_LENGTH=6
|
||||
UPLOADER_ADMIN_LIMIT=104900000
|
||||
UPLOADER_USER_LIMIT=104900000
|
||||
UPLOADER_DISABLED_EXTENSIONS=someext
|
||||
|
||||
URLS_ROUTE=/go
|
||||
URLS_LENGTH=6
|
||||
|
||||
RATELIMIT_USER = 5
|
||||
RATELIMIT_ADMIN = 3
|
||||
@@ -1,12 +1,36 @@
|
||||
{
|
||||
"extends": ["next", "next/core-web-vitals"],
|
||||
"extends": [
|
||||
"next",
|
||||
"next/core-web-vitals"
|
||||
],
|
||||
"rules": {
|
||||
"indent": ["error", 2],
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"quotes": ["error", "single"],
|
||||
"semi": ["error", "always"],
|
||||
"comma-dangle": ["error", "always-multiline"],
|
||||
"jsx-quotes": ["error", "prefer-single"],
|
||||
"indent": [
|
||||
"error",
|
||||
2,
|
||||
{
|
||||
"SwitchCase": 1
|
||||
}
|
||||
],
|
||||
"linebreak-style": [
|
||||
"error",
|
||||
"unix"
|
||||
],
|
||||
"quotes": [
|
||||
"error",
|
||||
"single"
|
||||
],
|
||||
"semi": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"comma-dangle": [
|
||||
"error",
|
||||
"always-multiline"
|
||||
],
|
||||
"jsx-quotes": [
|
||||
"error",
|
||||
"prefer-single"
|
||||
],
|
||||
"react/prop-types": "off",
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
@@ -17,9 +41,11 @@
|
||||
"react/no-direct-mutation-state": "warn",
|
||||
"react/no-is-mounted": "warn",
|
||||
"react/no-typos": "error",
|
||||
"react/react-in-jsx-scope": "error",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/require-render-return": "error",
|
||||
"react/style-prop-object": "warn",
|
||||
"@next/next/no-img-element": "off"
|
||||
"@next/next/no-img-element": "off",
|
||||
"jsx-a11y/alt-text": "off",
|
||||
"react/display-name": "off"
|
||||
}
|
||||
}
|
||||
9
.github/workflows/build.yml
vendored
9
.github/workflows/build.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: 'CI: Build'
|
||||
name: 'Build'
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -22,12 +22,11 @@ jobs:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
|
||||
|
||||
- name: Create mock config
|
||||
run: echo -e "[core]\nsecret = '12345678'\ndatabase_url = 'postgres://postgres:postgres@postgres/postgres'\n[uploader]\nroute = '/u'\ndirectory = './uploads'\n[urls]\nroute = '/go'" > config.toml
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache-restore.outputs.cache-hit != 'true'
|
||||
run: yarn install
|
||||
|
||||
- name: Build
|
||||
run: yarn build
|
||||
run: yarn build
|
||||
env:
|
||||
ZIPLINE_DOCKER_BUILD: true
|
||||
@@ -1,23 +1,31 @@
|
||||
name: 'CD: Push ARM64 Docker Images'
|
||||
name: 'Push Release Docker Images'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ trunk ]
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'server/**'
|
||||
- 'prisma/**'
|
||||
- '.github/**'
|
||||
- 'Dockerfile'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
push_to_ghcr:
|
||||
name: Push Image to GitHub Packages
|
||||
name: Push Release Image to GitHub Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Get version
|
||||
uses: sergeysova/jq-action@v2
|
||||
id: version
|
||||
with:
|
||||
cmd: 'jq .version package.json -r'
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
@@ -35,7 +43,8 @@ jobs:
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
file: ./Dockerfile-arm
|
||||
platforms: linux/arm64
|
||||
push: true
|
||||
tags: ghcr.io/diced/zipline/arm64:trunk
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
ghcr.io/diced/zipline:latest
|
||||
ghcr.io/diced/zipline:${{ steps.version.outputs.value }}
|
||||
14
.github/workflows/docker.yml
vendored
14
.github/workflows/docker.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: 'CD: Push Docker Images'
|
||||
name: 'Push Docker Images'
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
- 'server/**'
|
||||
- 'prisma/**'
|
||||
- '.github/**'
|
||||
- 'Dockerfile'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -32,17 +33,10 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
ghcr.io/diced/zipline/zipline:trunk
|
||||
ghcr.io/diced/zipline/amd64:trunk
|
||||
diced/zipline:trunk
|
||||
ghcr.io/diced/zipline:trunk
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -42,5 +42,4 @@ yarn-error.log*
|
||||
# zipline
|
||||
config.toml
|
||||
uploads/
|
||||
dist/
|
||||
docker-compose.local.yml
|
||||
dist/
|
||||
0
.yarn/releases/yarn-3.2.1.cjs
vendored
Normal file → Executable file
0
.yarn/releases/yarn-3.2.1.cjs
vendored
Normal file → Executable file
48
Dockerfile
48
Dockerfile
@@ -1,47 +1,63 @@
|
||||
FROM node:16-alpine AS deps
|
||||
FROM ghcr.io/diced/prisma-binaries:4.1.x as prisma
|
||||
|
||||
FROM alpine:3.16 AS deps
|
||||
RUN mkdir -p /prisma-engines
|
||||
WORKDIR /build
|
||||
|
||||
COPY .yarn .yarn
|
||||
COPY package.json yarn.lock .yarnrc.yml ./
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk add --no-cache nodejs yarn
|
||||
RUN yarn install --immutable
|
||||
|
||||
FROM node:16-alpine AS builder
|
||||
FROM alpine:3.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 nodejs yarn openssl openssl-dev
|
||||
|
||||
COPY --from=deps /build/node_modules ./node_modules
|
||||
COPY src ./src
|
||||
COPY scripts ./scripts
|
||||
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 ./
|
||||
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:16-alpine AS runner
|
||||
FROM alpine:3.16 AS runner
|
||||
WORKDIR /zipline
|
||||
|
||||
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 nodejs yarn openssl openssl-dev
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 zipline
|
||||
RUN adduser --system --uid 1001 zipline
|
||||
|
||||
COPY --from=builder --chown=zipline:zipline /build/.next ./.next
|
||||
COPY --from=builder --chown=zipline:zipline /build/dist ./dist
|
||||
COPY --from=builder --chown=zipline:zipline /build/node_modules ./node_modules
|
||||
COPY --from=builder /build/.next ./.next
|
||||
COPY --from=builder /build/node_modules ./node_modules
|
||||
|
||||
COPY --from=builder /build/next.config.js ./next.config.js
|
||||
COPY --from=builder /build/src ./src
|
||||
COPY --from=builder /build/scripts ./scripts
|
||||
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
|
||||
|
||||
USER zipline
|
||||
|
||||
CMD ["node", "dist/server"]
|
||||
CMD ["node_modules/.bin/tsx", "src/server"]
|
||||
@@ -1,46 +0,0 @@
|
||||
FROM node:16 AS deps
|
||||
WORKDIR /build
|
||||
|
||||
COPY .yarn .yarn
|
||||
COPY package.json yarn.lock .yarnrc.yml ./
|
||||
|
||||
RUN yarn install --immutable
|
||||
|
||||
FROM node:16 AS builder
|
||||
WORKDIR /build
|
||||
|
||||
COPY --from=deps /build/node_modules ./node_modules
|
||||
COPY src ./src
|
||||
COPY scripts ./scripts
|
||||
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 ./
|
||||
|
||||
ENV ZIPLINE_DOCKER_BUILD 1
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN yarn build
|
||||
|
||||
FROM node:16 AS runner
|
||||
WORKDIR /zipline
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 zipline
|
||||
RUN adduser --system --uid 1001 zipline
|
||||
|
||||
COPY --from=builder --chown=zipline:zipline /build/.next ./.next
|
||||
COPY --from=builder --chown=zipline:zipline /build/dist ./dist
|
||||
COPY --from=builder --chown=zipline:zipline /build/node_modules ./node_modules
|
||||
|
||||
COPY --from=builder /build/next.config.js ./next.config.js
|
||||
COPY --from=builder /build/src ./src
|
||||
COPY --from=builder /build/scripts ./scripts
|
||||
COPY --from=builder /build/prisma ./prisma
|
||||
COPY --from=builder /build/tsconfig.json ./tsconfig.json
|
||||
COPY --from=builder /build/package.json ./package.json
|
||||
|
||||
USER zipline
|
||||
|
||||
CMD ["node", "dist/server"]
|
||||
15
README.md
15
README.md
@@ -1,14 +1,14 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/diced/zipline/trunk/public/zipline_small.png"/>
|
||||
|
||||
|
||||
Zipline is a ShareX/file upload server that is easy to use, packed with features and can be setup in one command!
|
||||
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://discord.gg/EAhCRfGxCF)
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
## Features
|
||||
@@ -17,6 +17,7 @@
|
||||
- Built with Next.js & React
|
||||
- Token protected uploading
|
||||
- Image uploading
|
||||
- Image compression
|
||||
- Password Protected Uploads
|
||||
- URL shortening
|
||||
- Text uploading
|
||||
@@ -57,7 +58,7 @@ yarn start
|
||||
```
|
||||
|
||||
# NGINX Proxy
|
||||
This section requires [nginx](https://nginx.org/).
|
||||
This section requires [NGINX](https://nginx.org/).
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -83,7 +84,7 @@ This section requires [ShareX](https://www.getsharex.com/).
|
||||
After navigating to Zipline, click on the top right corner where it says your username and click Manage Account. Scroll down to see "ShareX Config", select the one you would prefer using. After this you can import the .sxcu into sharex. [More information here](https://zipl.vercel.app/docs/uploaders/sharex)
|
||||
|
||||
# Flameshot (Linux)
|
||||
This section requires [Flameshot](https://www.flameshot.org/), [jq](https://stedolan.github.io/jq/) and xsel.
|
||||
This section requires [Flameshot](https://www.flameshot.org/), [jq](https://stedolan.github.io/jq/), and [xsel](https://github.com/kfish/xsel).
|
||||
|
||||
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.
|
||||
|
||||
@@ -95,9 +96,9 @@ curl -H "Content-Type: multipart/form-data" -H "authorization: $TOKEN" -F file=@
|
||||
```
|
||||
|
||||
# Contributing
|
||||
|
||||
|
||||
## Bug reports
|
||||
Create an issue on GitHub, please include the following:
|
||||
Create an issue on GitHub, please include the following (if one of them is not applicable to the issue then it's not needed):
|
||||
* The steps to reproduce the bug
|
||||
* Logs of Zipline
|
||||
* The version of Zipline
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
[core]
|
||||
secure = true
|
||||
secret = 'changethis'
|
||||
host = '0.0.0.0'
|
||||
port = 3000
|
||||
database_url = 'postgres://postgres:postgres@postgres/postgres'
|
||||
|
||||
[urls]
|
||||
route = '/go'
|
||||
length = 6
|
||||
|
||||
[uploader]
|
||||
route = '/u'
|
||||
embed_route = '/a'
|
||||
length = 6
|
||||
directory = './uploads'
|
||||
user_limit = 104900000 # 100mb
|
||||
admin_limit = 104900000 # 100mb
|
||||
disabled_extensions = ['jpg']
|
||||
@@ -1,46 +0,0 @@
|
||||
version: '3'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DATABASE=postgres
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
zipline:
|
||||
image: ghcr.io/diced/zipline/arm64:trunk
|
||||
ports:
|
||||
- '3000:3000'
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- SECURE=false
|
||||
- SECRET=changethis
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- DATASOURCE_TYPE=local
|
||||
- DATASOURCE_LOCAL_DIRECTORY=./uploads
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
|
||||
- UPLOADER_ROUTE=/u
|
||||
- UPLOADER_EMBED_ROUTE=/a
|
||||
- UPLOADER_LENGTH=6
|
||||
- UPLOADER_ADMIN_LIMIT=104900000
|
||||
- UPLOADER_USER_LIMIT=104900000
|
||||
- UPLOADER_DISABLED_EXTS=
|
||||
- URLS_ROUTE=/go
|
||||
- URLS_LENGTH=6
|
||||
volumes:
|
||||
- '$PWD/uploads:/zipline/uploads'
|
||||
- '$PWD/public:/zipline/public'
|
||||
depends_on:
|
||||
- 'postgres'
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
@@ -23,21 +23,12 @@ services:
|
||||
- '3000:3000'
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- SECURE=false
|
||||
- SECRET=changethis
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- DATASOURCE_TYPE=local
|
||||
- DATASOURCE_LOCAL_DIRECTORY=./uploads
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
|
||||
- UPLOADER_ROUTE=/u
|
||||
- UPLOADER_EMBED_ROUTE=/a
|
||||
- UPLOADER_LENGTH=6
|
||||
- UPLOADER_ADMIN_LIMIT=104900000
|
||||
- UPLOADER_USER_LIMIT=104900000
|
||||
- UPLOADER_DISABLED_EXTS=
|
||||
- URLS_ROUTE=/go
|
||||
- URLS_LENGTH=6
|
||||
- CORE_HTTPS=false
|
||||
- CORE_SECRET=changethislol
|
||||
- CORE_HOST=0.0.0.0
|
||||
- CORE_PORT=3000
|
||||
- CORE_DATABASE_URL=postgres://postgres:postgres@postgres/postgres
|
||||
- CORE_LOGGER=true
|
||||
volumes:
|
||||
- '$PWD/uploads:/zipline/uploads'
|
||||
- '$PWD/public:/zipline/public'
|
||||
|
||||
@@ -16,28 +16,19 @@ services:
|
||||
retries: 5
|
||||
|
||||
zipline:
|
||||
image: ghcr.io/diced/zipline/zipline:trunk
|
||||
image: ghcr.io/diced/zipline
|
||||
ports:
|
||||
- '3000:3000'
|
||||
restart: always
|
||||
environment:
|
||||
- SECURE=false
|
||||
- SECRET=changethis
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- DATASOURCE_TYPE=local
|
||||
- DATASOURCE_DIRECTORY=./uploads
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
|
||||
- UPLOADER_ROUTE=/u
|
||||
- UPLOADER_EMBED_ROUTE=/a
|
||||
- UPLOADER_LENGTH=6
|
||||
- UPLOADER_ADMIN_LIMIT=104900000
|
||||
- UPLOADER_USER_LIMIT=104900000
|
||||
- UPLOADER_DISABLED_EXTS=
|
||||
- URLS_ROUTE=/go
|
||||
- URLS_LENGTH=6
|
||||
- CORE_HTTPS=false
|
||||
- CORE_SECRET=changethis
|
||||
- CORE_HOST=0.0.0.0
|
||||
- CORE_PORT=3000
|
||||
- CORE_DATABASE_URL=postgres://postgres:postgres@postgres/postgres
|
||||
- CORE_LOGGER=true
|
||||
volumes:
|
||||
- '$PWD/uploads:/zipline/uploads'
|
||||
- './uploads:/zipline/uploads'
|
||||
- '$PWD/public:/zipline/public'
|
||||
depends_on:
|
||||
- 'postgres'
|
||||
|
||||
@@ -20,6 +20,8 @@ const { rm } = require('fs/promises');
|
||||
'src/server/util.ts',
|
||||
'src/lib/logger.ts',
|
||||
'src/lib/config.ts',
|
||||
'src/lib/mimes.ts',
|
||||
'src/lib/exts.ts',
|
||||
'src/lib/config/Config.ts',
|
||||
'src/lib/config/readConfig.ts',
|
||||
'src/lib/config/validateConfig.ts',
|
||||
@@ -27,6 +29,7 @@ const { rm } = require('fs/promises');
|
||||
'src/lib/datasources/index.ts',
|
||||
'src/lib/datasources/Local.ts',
|
||||
'src/lib/datasources/S3.ts',
|
||||
'src/lib/datasources/Swift.ts',
|
||||
'src/lib/datasource.ts',
|
||||
],
|
||||
format: 'cjs',
|
||||
@@ -34,7 +37,7 @@ const { rm } = require('fs/promises');
|
||||
write: true,
|
||||
watch,
|
||||
incremental: watch,
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
sourcemap: true,
|
||||
minify: false,
|
||||
});
|
||||
})();
|
||||
8298
mimes.json
Normal file
8298
mimes.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
**/
|
||||
module.exports = {
|
||||
async redirects() {
|
||||
return [
|
||||
@@ -8,7 +11,6 @@ module.exports = {
|
||||
},
|
||||
];
|
||||
},
|
||||
api: {
|
||||
responseLimit: false,
|
||||
},
|
||||
poweredByHeader: false,
|
||||
reactStrictMode: true,
|
||||
};
|
||||
47
package.json
47
package.json
@@ -1,63 +1,70 @@
|
||||
{
|
||||
"name": "zipline",
|
||||
"version": "3.4.6",
|
||||
"version": "3.5.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.js && REACT_EDITOR=code NODE_ENV=development node dist/server",
|
||||
"build": "npm-run-all build:server build:schema build:next",
|
||||
"build:server": "node esbuild.config.js",
|
||||
"dev": "REACT_EDITOR=code NODE_ENV=development tsx src/server",
|
||||
"build": "npm-run-all build:schema build:next",
|
||||
"build:next": "next build",
|
||||
"build:schema": "prisma generate --schema=prisma/schema.prisma",
|
||||
"migrate:dev": "prisma migrate dev --create-only",
|
||||
"start": "node dist/server",
|
||||
"start": "tsx src/server",
|
||||
"lint": "next lint",
|
||||
"docker:run": "docker-compose up -d",
|
||||
"docker:down": "docker-compose down",
|
||||
"docker:build-dev": "docker-compose --file docker-compose.dev.yml up --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.9.3",
|
||||
"@emotion/server": "^11.4.0",
|
||||
"@iarna/toml": "2.2.5",
|
||||
"@mantine/core": "^4.2.9",
|
||||
"@mantine/dropzone": "^4.2.9",
|
||||
"@mantine/hooks": "^4.2.9",
|
||||
"@mantine/modals": "^4.2.9",
|
||||
"@mantine/next": "^4.2.9",
|
||||
"@mantine/notifications": "^4.2.9",
|
||||
"@mantine/prism": "^4.2.9",
|
||||
"@modulz/radix-icons": "^4.0.0",
|
||||
"@prisma/client": "^3.15.2",
|
||||
"@prisma/migrate": "^3.15.2",
|
||||
"@prisma/sdk": "^3.15.2",
|
||||
"@mantine/core": "^5.0.0",
|
||||
"@mantine/dropzone": "^5.0.0",
|
||||
"@mantine/form": "^5.0.0",
|
||||
"@mantine/hooks": "^5.0.0",
|
||||
"@mantine/modals": "^5.0.0",
|
||||
"@mantine/next": "^5.0.0",
|
||||
"@mantine/notifications": "^5.0.0",
|
||||
"@mantine/nprogress": "^5.0.0",
|
||||
"@mantine/prism": "^5.0.0",
|
||||
"@prisma/client": "^4.1.0",
|
||||
"@prisma/internals": "^4.1.0",
|
||||
"@prisma/migrate": "^4.1.0",
|
||||
"@reduxjs/toolkit": "^1.8.2",
|
||||
"argon2": "^0.28.5",
|
||||
"aws-sdk": "^2.1156.0",
|
||||
"colorette": "^2.0.19",
|
||||
"cookie": "^0.5.0",
|
||||
"dotenv": "^16.0.1",
|
||||
"dotenv-expand": "^8.0.3",
|
||||
"fecha": "^4.2.3",
|
||||
"fflate": "^0.7.3",
|
||||
"find-my-way": "^6.3.0",
|
||||
"minio": "^7.0.28",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"next": "^12.1.6",
|
||||
"prisma": "^3.15.2",
|
||||
"prisma": "^4.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-feather": "^2.0.10",
|
||||
"react-redux": "^8.0.2",
|
||||
"react-table": "^7.8.0",
|
||||
"redux": "^4.2.0",
|
||||
"redux-thunk": "^2.4.1",
|
||||
"uuid": "^8.3.2",
|
||||
"sharp": "^0.30.7",
|
||||
"yup": "^0.32.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie": "^0.5.1",
|
||||
"@types/minio": "^7.0.13",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^15.12.2",
|
||||
"@types/sharp": "^0.30.5",
|
||||
"babel-plugin-import": "^1.13.5",
|
||||
"esbuild": "^0.14.44",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-next": "12.1.6",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"ts-node": "^10.8.1",
|
||||
"tsx": "^3.8.0",
|
||||
"typescript": "^4.7.3"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
17
prisma/migrations/20220713164531_invites/migration.sql
Normal file
17
prisma/migrations/20220713164531_invites/migration.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Invite" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"used" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdById" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Invite_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Invite_code_key" ON "Invite"("code");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Invite" ALTER COLUMN "expires_at" DROP NOT NULL,
|
||||
ALTER COLUMN "expires_at" DROP DEFAULT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" ADD COLUMN "expires_at" TIMESTAMP(3);
|
||||
2
prisma/migrations/20220816212407_avatar/migration.sql
Normal file
2
prisma/migrations/20220816212407_avatar/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "avatar" TEXT;
|
||||
@@ -11,6 +11,7 @@ model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String
|
||||
password String
|
||||
avatar String?
|
||||
token String
|
||||
administrator Boolean @default(false)
|
||||
systemTheme String @default("system")
|
||||
@@ -21,6 +22,7 @@ model User {
|
||||
domains String[]
|
||||
images Image[]
|
||||
urls Url[]
|
||||
Invite Invite[]
|
||||
}
|
||||
|
||||
enum ImageFormat {
|
||||
@@ -35,6 +37,7 @@ model Image {
|
||||
file String
|
||||
mimetype String @default("image/png")
|
||||
created_at DateTime @default(now())
|
||||
expires_at DateTime?
|
||||
views Int @default(0)
|
||||
favorite Boolean @default(false)
|
||||
embed Boolean @default(false)
|
||||
@@ -75,3 +78,14 @@ model Stats {
|
||||
created_at 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])
|
||||
createdById Int
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// https://github.com/toptal/haste-server/blob/master/static/application.js#L167-L174
|
||||
// Popular extension map
|
||||
module.exports = {
|
||||
rb: 'ruby',
|
||||
py: 'python',
|
||||
pl: 'perl',
|
||||
php: 'php',
|
||||
scala: 'scala',
|
||||
go: 'go',
|
||||
xml: 'xml',
|
||||
html: 'xml',
|
||||
htm: 'xml',
|
||||
css: 'css',
|
||||
js: 'javascript',
|
||||
json: 'json',
|
||||
vbs: 'vbscript',
|
||||
lua: 'lua',
|
||||
pas: 'delphi',
|
||||
java: 'java',
|
||||
cpp: 'cpp',
|
||||
cc: 'cpp',
|
||||
m: 'objectivec',
|
||||
vala: 'vala',
|
||||
sql: 'sql',
|
||||
sm: 'smalltalk',
|
||||
lisp: 'lisp',
|
||||
ini: 'ini',
|
||||
diff: 'diff',
|
||||
bash: 'bash',
|
||||
sh: 'bash',
|
||||
tex: 'tex',
|
||||
erl: 'erlang',
|
||||
hs: 'haskell',
|
||||
md: 'markdown',
|
||||
txt: '',
|
||||
coffee: 'coffee',
|
||||
swift: 'swift',
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
const { readdir } = require('fs/promises');
|
||||
const { extname } = require('path');
|
||||
const validateConfig = require('../server/validateConfig');
|
||||
const Logger = require('../src/lib/logger');
|
||||
const readConfig = require('../src/lib/readConfig');
|
||||
const mimes = require('./mimes');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
(async () => {
|
||||
const config = readConfig();
|
||||
|
||||
await validateConfig(config);
|
||||
|
||||
process.env.DATABASE_URL = config.core.database_url;
|
||||
|
||||
const files = await readdir(process.argv[2]);
|
||||
const data = files.map(x => {
|
||||
const mime = mimes[extname(x)] ?? 'application/octet-stream';
|
||||
|
||||
return {
|
||||
file: x,
|
||||
mimetype: mime,
|
||||
userId: 1,
|
||||
};
|
||||
});
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
Logger.get('migrator').info('starting migrations...');
|
||||
await prisma.image.createMany({
|
||||
data,
|
||||
});
|
||||
Logger.get('migrator').info('finished migrations! It is recomended to move your old uploads folder (' + process.argv[2] + ') to the current one which is ' + config.uploader.directory);
|
||||
process.exit();
|
||||
})();
|
||||
@@ -1,78 +0,0 @@
|
||||
module.exports = {
|
||||
'.aac': 'audio/aac',
|
||||
'.abw': 'application/x-abiword',
|
||||
'.arc': 'application/x-freearc',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.azw': 'application/vnd.amazon.ebook',
|
||||
'.bin': 'application/octet-stream',
|
||||
'.bmp': 'image/bmp',
|
||||
'.bz': 'application/x-bzip',
|
||||
'.bz2': 'application/x-bzip2',
|
||||
'.cda': 'application/x-cdf',
|
||||
'.csh': 'application/x-csh',
|
||||
'.css': 'text/css',
|
||||
'.csv': 'text/csv',
|
||||
'.doc': 'application/msword',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.eot': 'application/vnd.ms-fontobject',
|
||||
'.epub': 'application/epub+zip',
|
||||
'.gz': 'application/gzip',
|
||||
'.gif': 'image/gif',
|
||||
'.htm': 'text/html',
|
||||
'.html': 'text/html',
|
||||
'.ico': 'image/vnd.microsoft.icon',
|
||||
'.ics': 'text/calendar',
|
||||
'.jar': 'application/java-archive',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'text/javascript',
|
||||
'.json': 'application/json',
|
||||
'.jsonld': 'application/ld+json',
|
||||
'.mid': 'audio/midi',
|
||||
'.midi': 'audio/midi',
|
||||
'.mjs': 'text/javascript',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.mp4': 'video/mp4',
|
||||
'.mpeg': 'video/mpeg',
|
||||
'.mpkg': 'application/vnd.apple.installer+xml',
|
||||
'.odp': 'application/vnd.oasis.opendocument.presentation',
|
||||
'.ods': 'application/vnd.oasis.opendocument.spreadsheet',
|
||||
'.odt': 'application/vnd.oasis.opendocument.text',
|
||||
'.oga': 'audio/ogg',
|
||||
'.ogv': 'video/ogg',
|
||||
'.ogx': 'application/ogg',
|
||||
'.opus': 'audio/opus',
|
||||
'.otf': 'font/otf',
|
||||
'.png': 'image/png',
|
||||
'.pdf': 'application/pdf',
|
||||
'.php': 'application/x-httpd-php',
|
||||
'.ppt': 'application/vnd.ms-powerpoint',
|
||||
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'.rar': 'application/vnd.rar',
|
||||
'.rtf': 'application/rtf',
|
||||
'.sh': 'application/x-sh',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.swf': 'application/x-shockwave-flash',
|
||||
'.tar': 'application/x-tar',
|
||||
'.tif': 'image/tiff',
|
||||
'.tiff': 'image/tiff',
|
||||
'.ts': 'video/mp2t',
|
||||
'.ttf': 'font/ttf',
|
||||
'.txt': 'text/plain',
|
||||
'.vsd': 'application/vnd.visio',
|
||||
'.wav': 'audio/wav',
|
||||
'.weba': 'audio/webm',
|
||||
'.webm': 'video/webm',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.xhtml': 'application/xhtml+xml',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.xml': 'application/xml',
|
||||
'.xul': 'application/vnd.mozilla.xul+xml',
|
||||
'.zip': 'application/zip',
|
||||
'.3gp': 'video/3gpp',
|
||||
'.3g2': 'video/3gpp2',
|
||||
'.7z': 'application/x-7z-compressed',
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import React from 'react';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
|
||||
export default function Backdrop({ open }) {
|
||||
return (
|
||||
<LoadingOverlay visible={open} />
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card as MCard,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { Card as MCard, Title } from '@mantine/core';
|
||||
|
||||
export default function Card(props) {
|
||||
const { name, children, ...other } = props;
|
||||
export default function Card({ name, children, ...other }) {
|
||||
|
||||
return (
|
||||
<MCard padding='md' shadow='sm' {...other}>
|
||||
<MCard p='md' shadow='sm' {...other}>
|
||||
<Title order={2}>{name}</Title>
|
||||
{children}
|
||||
</MCard>
|
||||
|
||||
21
src/components/CodeInput.tsx
Normal file
21
src/components/CodeInput.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createStyles, MantineSize, Textarea } from '@mantine/core';
|
||||
|
||||
const useStyles = createStyles((theme, { size }: { size: MantineSize }) => ({
|
||||
input: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: theme.fn.size({ size, sizes: theme.fontSizes }) - 2,
|
||||
height: '100vh',
|
||||
},
|
||||
}));
|
||||
|
||||
export default function CodeInput({ ...props }) {
|
||||
const { classes } = useStyles({ size: 'md' }, { name: 'CodeInput' });
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
classNames={{ input: classes.input }}
|
||||
autoComplete='nope'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
116
src/components/File.tsx
Normal file
116
src/components/File.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Button, Card, Group, Modal, Stack, Text, Title, useMantineTheme } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useState } from 'react';
|
||||
import Type from './Type';
|
||||
import { CalendarIcon, ClockIcon, CopyIcon, CrossIcon, DeleteIcon, FileIcon, HashIcon, ImageIcon, StarIcon } from './icons';
|
||||
import MutedText from './MutedText';
|
||||
import { relativeTime } from 'lib/clientUtils';
|
||||
|
||||
export function FileMeta({ Icon, title, subtitle }) {
|
||||
return (
|
||||
<Group>
|
||||
<Icon size={24} />
|
||||
<Stack spacing={1}>
|
||||
<Text>{title}</Text>
|
||||
<MutedText size='md'>{subtitle}</MutedText>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function File({ image, updateImages }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
|
||||
if (!res.error) {
|
||||
updateImages(true);
|
||||
showNotification({
|
||||
title: 'File Deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <DeleteIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to delete file',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${image.url}`);
|
||||
setOpen(false);
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
const data = await useFetch('/api/user/files', 'PATCH', { id: image.id, favorite: !image.favorite });
|
||||
if (!data.error) updateImages(true);
|
||||
showNotification({
|
||||
title: 'Image is now ' + (!image.favorite ? 'favorited' : 'unfavorited'),
|
||||
message: '',
|
||||
icon: <StarIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Title>{image.file}</Title>}
|
||||
size='xl'
|
||||
>
|
||||
<Stack>
|
||||
<Type
|
||||
file={image}
|
||||
src={image.url}
|
||||
alt={image.file}
|
||||
popup
|
||||
sx={{ minHeight: 200 }}
|
||||
style={{ minHeight: 200 }}
|
||||
/>
|
||||
<Stack>
|
||||
<FileMeta Icon={FileIcon} title='Name' subtitle={image.file} />
|
||||
<FileMeta Icon={ImageIcon} title='Type' subtitle={image.mimetype} />
|
||||
<FileMeta Icon={CalendarIcon} title='Uploaded at' subtitle={new Date(image.created_at).toLocaleString()} />
|
||||
{image.expires_at && <FileMeta Icon={ClockIcon} title='Expires' subtitle={relativeTime(new Date(image.expires_at))} />}
|
||||
<FileMeta Icon={HashIcon} title='ID' subtitle={image.id} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Group position='right' mt={22}>
|
||||
<Button onClick={handleCopy}>Copy</Button>
|
||||
<Button onClick={handleDelete}>Delete</Button>
|
||||
<Button onClick={handleFavorite}>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
|
||||
<Card.Section>
|
||||
<Type
|
||||
file={image}
|
||||
sx={{ minHeight: 200, maxHeight: 320, fontSize: 70, width: '100%', cursor: 'pointer' }}
|
||||
style={{ minHeight: 200, maxHeight: 320, fontSize: 70, width: '100%', cursor: 'pointer' }}
|
||||
src={image.url}
|
||||
alt={image.file}
|
||||
onClick={() => setOpen(true)}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { Button, Card, Group, Image as MImage, Modal, Title } from '@mantine/core';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { CopyIcon, Cross1Icon, StarIcon, TrashIcon } from '@modulz/radix-icons';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
|
||||
export default function Image({ image, updateImages }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [t] = useState(image.mimetype.split('/')[0]);
|
||||
const notif = useNotifications();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
|
||||
if (!res.error) {
|
||||
updateImages(true);
|
||||
notif.showNotification({
|
||||
title: 'Image Deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <TrashIcon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'Failed to delete image',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${image.url}`);
|
||||
setOpen(false);
|
||||
notif.showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
const data = await useFetch('/api/user/files', 'PATCH', { id: image.id, favorite: !image.favorite });
|
||||
if (!data.error) updateImages(true);
|
||||
notif.showNotification({
|
||||
title: 'Image is now ' + (!image.favorite ? 'favorited' : 'unfavorited'),
|
||||
message: '',
|
||||
icon: <StarIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const Type = (props) => {
|
||||
return {
|
||||
'video': <video controls {...props} />,
|
||||
'image': <MImage {...props} />,
|
||||
'audio': <audio controls {...props} />,
|
||||
}[t];
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Title>{image.file}</Title>}
|
||||
>
|
||||
<Type
|
||||
src={image.url}
|
||||
alt={image.file}
|
||||
/>
|
||||
<Group position='right' mt={22}>
|
||||
<Button onClick={handleCopy}>Copy</Button>
|
||||
<Button onClick={handleDelete}>Delete</Button>
|
||||
<Button onClick={handleFavorite}>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
|
||||
<Card.Section>
|
||||
<Type
|
||||
sx={{ maxHeight: 320, fontSize: 70, width: '100%', cursor: 'pointer' }}
|
||||
style={{ maxHeight: 320, fontSize: 70, width: '100%', cursor: 'pointer' }}
|
||||
src={image.url}
|
||||
alt={image.file}
|
||||
onClick={() => setOpen(true)}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,20 @@
|
||||
/* eslint-disable react/jsx-key */
|
||||
/* eslint-disable react/display-name */
|
||||
// Code taken from https://codesandbox.io/s/eojw8 and is modified a bit (the entire src/components/table directory)
|
||||
import React from 'react';
|
||||
import {
|
||||
usePagination,
|
||||
useTable,
|
||||
} from 'react-table';
|
||||
import {
|
||||
ActionIcon,
|
||||
createStyles,
|
||||
Divider,
|
||||
Group,
|
||||
Pagination,
|
||||
Group, Image, Pagination,
|
||||
Select,
|
||||
Table,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
CopyIcon,
|
||||
EnterIcon,
|
||||
TrashIcon,
|
||||
} from '@modulz/radix-icons';
|
||||
usePagination,
|
||||
useTable,
|
||||
} from 'react-table';
|
||||
import { CopyIcon, DeleteIcon, EnterIcon } from './icons';
|
||||
|
||||
const pageSizeOptions = ['10', '25', '50'];
|
||||
|
||||
@@ -42,6 +35,26 @@ const useStyles = createStyles((t) => ({
|
||||
sortDirectionIcon: { transition: 'transform 200ms ease' },
|
||||
}));
|
||||
|
||||
export function FilePreview({ url, type }) {
|
||||
const Type = props => {
|
||||
return {
|
||||
'video': <video autoPlay controls {...props} />,
|
||||
'image': <Image {...props} />,
|
||||
'audio': <audio autoPlay controls {...props} />,
|
||||
}[type.split('/')[0]];
|
||||
};
|
||||
|
||||
return (
|
||||
<Type
|
||||
sx={{ maxWidth: '10vw', maxHeight: '100vh' }}
|
||||
style={{ maxWidth: '10vw', maxHeight: '100vh' }}
|
||||
mr='sm'
|
||||
src={url}
|
||||
alt={'Unable to preview file'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImagesTable({
|
||||
columns,
|
||||
data = [],
|
||||
@@ -71,17 +84,14 @@ export default function ImagesTable({
|
||||
|
||||
const getPageRecordInfo = () => {
|
||||
const firstRowNum = pageIndex * pageSize + 1;
|
||||
const totalRows = serverSideDataSource ? total : rows.length;
|
||||
const totalRows = rows.length;
|
||||
|
||||
const currLastRowNum = (pageIndex + 1) * pageSize;
|
||||
let lastRowNum = currLastRowNum < totalRows ? currLastRowNum : totalRows;
|
||||
return `${firstRowNum} - ${lastRowNum} of ${totalRows}`;
|
||||
};
|
||||
|
||||
const getPageCount = () => {
|
||||
const totalRows = serverSideDataSource ? total : rows.length;
|
||||
return Math.ceil(totalRows / pageSize);
|
||||
};
|
||||
const getPageCount = () => Math.ceil(rows.length / pageSize);
|
||||
|
||||
const handlePageChange = (pageNum) => gotoPage(pageNum - 1);
|
||||
|
||||
@@ -109,7 +119,7 @@ export default function ImagesTable({
|
||||
))}
|
||||
<td align='right'>
|
||||
<Group noWrap>
|
||||
<ActionIcon color='red' variant='outline' onClick={() => deleteImage(row)}><TrashIcon /></ActionIcon>
|
||||
<ActionIcon color='red' variant='outline' onClick={() => deleteImage(row)}><DeleteIcon /></ActionIcon>
|
||||
<ActionIcon color='primary' variant='outline' onClick={() => copyImage(row)}><CopyIcon /></ActionIcon>
|
||||
<ActionIcon color='green' variant='outline' onClick={() => viewImage(row)}><EnterIcon /></ActionIcon>
|
||||
</Group>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { useRouter } from 'next/router';
|
||||
import { useStoreDispatch } from 'lib/redux/store';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { CheckIcon, CopyIcon, Cross1Icon, FileIcon, GearIcon, HomeIcon, Link1Icon, ResetIcon, UploadIcon, PinRightIcon, PersonIcon, Pencil1Icon, MixerHorizontalIcon } from '@modulz/radix-icons';
|
||||
import { AppShell, Burger, Divider, Group, Header, MediaQuery, Navbar, Paper, Popover, ScrollArea, Select, Text, ThemeIcon, Title, UnstyledButton, useMantineTheme, Box } from '@mantine/core';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { AppShell, Box, Burger, Button, Divider, Header, MediaQuery, Navbar, NavLink, Paper, Popover, ScrollArea, Select, Stack, Text, Title, UnstyledButton, useMantineTheme, Group, Image } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
import { useStoreDispatch } from 'lib/redux/store';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { ActivityIcon, CheckIcon, CopyIcon, CrossIcon, DeleteIcon, FileIcon, HomeIcon, LinkIcon, LogoutIcon, PencilIcon, SettingsIcon, TagIcon, TypeIcon, UploadIcon, UserIcon } from './icons';
|
||||
import { friendlyThemeName, themes } from './Theming';
|
||||
|
||||
function MenuItemLink(props) {
|
||||
@@ -65,42 +64,61 @@ function MenuItem(props) {
|
||||
|
||||
const items = [
|
||||
{
|
||||
icon: <HomeIcon />,
|
||||
icon: <HomeIcon size={18} />,
|
||||
text: 'Home',
|
||||
link: '/dashboard',
|
||||
},
|
||||
{
|
||||
icon: <FileIcon />,
|
||||
icon: <FileIcon size={18} />,
|
||||
text: 'Files',
|
||||
link: '/dashboard/files',
|
||||
},
|
||||
{
|
||||
icon: <MixerHorizontalIcon />,
|
||||
icon: <ActivityIcon size={18} />,
|
||||
text: 'Stats',
|
||||
link: '/dashboard/stats',
|
||||
},
|
||||
{
|
||||
icon: <Link1Icon />,
|
||||
icon: <LinkIcon size={18} />,
|
||||
text: 'URLs',
|
||||
link: '/dashboard/urls',
|
||||
},
|
||||
{
|
||||
icon: <UploadIcon />,
|
||||
icon: <UploadIcon size={18} />,
|
||||
text: 'Upload',
|
||||
link: '/dashboard/upload',
|
||||
},
|
||||
{
|
||||
icon: <TypeIcon size={18} />,
|
||||
text: 'Upload Text',
|
||||
link: '/dashboard/text',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Layout({ children, user }) {
|
||||
const admin_items = [
|
||||
{
|
||||
icon: <UserIcon size={18} />,
|
||||
text: 'Users',
|
||||
link: '/dashboard/users',
|
||||
},
|
||||
{
|
||||
icon: <TagIcon size={18} />,
|
||||
text: 'Invites',
|
||||
link: '/dashboard/invites',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Layout({ children, user, title }) {
|
||||
const [token, setToken] = useState(user?.token);
|
||||
const [systemTheme, setSystemTheme] = useState(user.systemTheme ?? 'system');
|
||||
const [avatar, setAvatar] = useState(user.avatar ?? null);
|
||||
const [opened, setOpened] = useState(false); // navigation open
|
||||
const [open, setOpen] = useState(false); // manage acc dropdown
|
||||
|
||||
const router = useRouter();
|
||||
const dispatch = useStoreDispatch();
|
||||
const theme = useMantineTheme();
|
||||
const modals = useModals();
|
||||
const notif = useNotifications();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const handleUpdateTheme = async value => {
|
||||
@@ -112,18 +130,16 @@ export default function Layout({ children, user }) {
|
||||
dispatch(updateUser(newUser));
|
||||
router.replace(router.pathname);
|
||||
|
||||
notif.showNotification({
|
||||
showNotification({
|
||||
title: `Theme changed to ${friendlyThemeName[value]}`,
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <Pencil1Icon />,
|
||||
icon: <PencilIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const openResetToken = () => modals.openConfirmModal({
|
||||
title: 'Reset Token',
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
children: (
|
||||
<Text size='sm'>
|
||||
Once you reset your token, you will have to update any uploaders to use this new token.
|
||||
@@ -134,14 +150,14 @@ export default function Layout({ children, user }) {
|
||||
const a = await useFetch('/api/user/token', 'PATCH');
|
||||
if (!a.success) {
|
||||
setToken(a.success);
|
||||
notif.showNotification({
|
||||
showNotification({
|
||||
title: 'Token Reset Failed',
|
||||
message: a.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
showNotification({
|
||||
title: 'Token Reset',
|
||||
message: 'Your token has been reset. You will need to update any uploaders to use this new token.',
|
||||
color: 'green',
|
||||
@@ -155,8 +171,6 @@ export default function Layout({ children, user }) {
|
||||
|
||||
const openCopyToken = () => modals.openConfirmModal({
|
||||
title: 'Copy Token',
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
children: (
|
||||
<Text size='sm'>
|
||||
Make sure you don't share this token with anyone as they will be able to upload files on your behalf.
|
||||
@@ -166,7 +180,7 @@ export default function Layout({ children, user }) {
|
||||
onConfirm: async () => {
|
||||
clipboard.copy(token);
|
||||
|
||||
notif.showNotification({
|
||||
showNotification({
|
||||
title: 'Token Copied',
|
||||
message: 'Your token has been copied to your clipboard.',
|
||||
color: 'green',
|
||||
@@ -183,7 +197,7 @@ export default function Layout({ children, user }) {
|
||||
fixed
|
||||
navbar={
|
||||
<Navbar
|
||||
p='md'
|
||||
pt='sm'
|
||||
hiddenBreakpoint='sm'
|
||||
hidden={!opened}
|
||||
width={{ sm: 200, lg: 230 }}
|
||||
@@ -191,59 +205,37 @@ export default function Layout({ children, user }) {
|
||||
<Navbar.Section
|
||||
grow
|
||||
component={ScrollArea}
|
||||
ml={-10}
|
||||
mr={-10}
|
||||
sx={{ paddingLeft: 10, paddingRight: 10 }}
|
||||
>
|
||||
{items.map(({ icon, text, link }) => (
|
||||
<Link href={link} key={text} passHref>
|
||||
<UnstyledButton
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: theme.spacing.xs,
|
||||
borderRadius: theme.radius.sm,
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.black,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.other.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group>
|
||||
<ThemeIcon color='primary' variant='filled'>
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
|
||||
<Text size='lg'>{text}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<NavLink
|
||||
component='a'
|
||||
label={text}
|
||||
icon={icon}
|
||||
active={router.pathname === link}
|
||||
variant='light'
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
{user.administrator && (
|
||||
<Link href='/dashboard/users' passHref>
|
||||
<UnstyledButton
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: theme.spacing.xs,
|
||||
borderRadius: theme.radius.sm,
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.black,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.other.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group>
|
||||
<ThemeIcon color='primary' variant='filled'>
|
||||
<PersonIcon />
|
||||
</ThemeIcon>
|
||||
|
||||
<Text size='lg'>Users</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Link>
|
||||
<NavLink
|
||||
label='Administration'
|
||||
icon={<SettingsIcon />}
|
||||
childrenOffset={28}
|
||||
defaultOpened={admin_items.map(x => x.link).includes(router.pathname)}
|
||||
>
|
||||
{admin_items.map(({ icon, text, link }) => (
|
||||
<Link href={link} key={text} passHref>
|
||||
<NavLink
|
||||
component='a'
|
||||
label={text}
|
||||
icon={icon}
|
||||
active={router.pathname === link}
|
||||
variant='light'
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</NavLink>
|
||||
)}
|
||||
</Navbar.Section>
|
||||
</Navbar>
|
||||
@@ -259,75 +251,81 @@ export default function Layout({ children, user }) {
|
||||
color={theme.colors.gray[6]}
|
||||
/>
|
||||
</MediaQuery>
|
||||
<Title sx={{ marginLeft: 12 }}>Zipline</Title>
|
||||
<Title ml='sm'>{title}</Title>
|
||||
<Box sx={{ marginLeft: 'auto', marginRight: 0 }}>
|
||||
<Popover
|
||||
position='top'
|
||||
placement='end'
|
||||
spacing={4}
|
||||
position='bottom-end'
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
target={
|
||||
<UnstyledButton
|
||||
onClick={() => setOpen(!open)}
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: theme.spacing.xs,
|
||||
borderRadius: theme.radius.sm,
|
||||
color: theme.other.color,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.other.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group>
|
||||
<ThemeIcon color='primary' variant='filled'>
|
||||
<GearIcon />
|
||||
</ThemeIcon>
|
||||
<Text>{user.username}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
}
|
||||
>
|
||||
<Group direction='column' spacing={2}>
|
||||
<Text sx={{
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6],
|
||||
fontWeight: 500,
|
||||
fontSize: theme.fontSizes.xs,
|
||||
padding: `${theme.spacing.xs / 2}px ${theme.spacing.sm}px`,
|
||||
cursor: 'default',
|
||||
}}>User: {user.username}</Text>
|
||||
<MenuItemLink icon={<GearIcon />} href='/dashboard/manage'>Manage Account</MenuItemLink>
|
||||
<MenuItem icon={<CopyIcon />} onClick={() => {setOpen(false);openCopyToken();}}>Copy Token</MenuItem>
|
||||
<MenuItem icon={<ResetIcon />} onClick={() => {setOpen(false);openResetToken();}} color='red'>Reset Token</MenuItem>
|
||||
<MenuItemLink icon={<PinRightIcon />} href='/auth/logout' color='red'>Logout</MenuItemLink>
|
||||
<Divider
|
||||
variant='solid'
|
||||
my={theme.spacing.xs / 2}
|
||||
sx={theme => ({
|
||||
width: '110%',
|
||||
borderTopColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[2],
|
||||
margin: `${theme.spacing.xs / 2}px -4px`,
|
||||
<Popover.Target>
|
||||
<Button
|
||||
leftIcon={avatar ? <Image src={avatar} height={32} radius='md' /> : <SettingsIcon />}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
sx={t => ({
|
||||
backgroundColor: '#00000000',
|
||||
'&:hover': {
|
||||
backgroundColor: t.other.hover,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<MenuItem icon={<Pencil1Icon />}>
|
||||
<Select
|
||||
size='xs'
|
||||
data={Object.keys(themes).map(t => ({ value: t, label: friendlyThemeName[t] }))}
|
||||
value={systemTheme}
|
||||
onChange={handleUpdateTheme}
|
||||
size='xl'
|
||||
p='sm'
|
||||
>
|
||||
{user.username}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown p={4}>
|
||||
<Stack spacing={2}>
|
||||
<Text sx={{
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6],
|
||||
fontWeight: 500,
|
||||
fontSize: theme.fontSizes.sm,
|
||||
padding: `${theme.spacing.xs / 2}px ${theme.spacing.sm}px`,
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
{user.username}
|
||||
</Text>
|
||||
<MenuItemLink icon={<SettingsIcon />} href='/dashboard/manage'>Manage Account</MenuItemLink>
|
||||
<MenuItem icon={<CopyIcon />} onClick={() => {setOpen(false);openCopyToken();}}>Copy Token</MenuItem>
|
||||
<MenuItem icon={<DeleteIcon />} onClick={() => {setOpen(false);openResetToken();}} color='red'>Reset Token</MenuItem>
|
||||
<MenuItemLink icon={<LogoutIcon />} href='/auth/logout' color='red'>Logout</MenuItemLink>
|
||||
<Divider
|
||||
variant='solid'
|
||||
my={theme.spacing.xs / 2}
|
||||
sx={theme => ({
|
||||
width: '110%',
|
||||
borderTopColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[2],
|
||||
margin: `${theme.spacing.xs / 2}px -4px`,
|
||||
})}
|
||||
/>
|
||||
</MenuItem>
|
||||
</Group>
|
||||
<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>
|
||||
</Box>
|
||||
</div>
|
||||
</Header>
|
||||
}
|
||||
>
|
||||
<Paper withBorder p='md' shadow='xs'>{children}</Paper>
|
||||
<Paper
|
||||
withBorder
|
||||
p='md'
|
||||
shadow='xs'
|
||||
sx={t => ({
|
||||
borderColor: t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.dark[0],
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</Paper>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +1,3 @@
|
||||
/// https://github.com/mui-org/material-ui/blob/next/examples/nextjs/src/Link.js
|
||||
/* eslint-disable jsx-a11y/anchor-has-content */
|
||||
import React, { forwardRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useRouter } from 'next/router';
|
||||
import NextLink from 'next/link';
|
||||
import { Text } from '@mantine/core';
|
||||
|
||||
export const NextLinkComposed = forwardRef(function NextLinkComposed(props: any, ref) {
|
||||
const { to, linkAs, href, replace, scroll, passHref, shallow, prefetch, locale, ...other } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<NextLink
|
||||
href={to}
|
||||
prefetch={prefetch}
|
||||
as={linkAs}
|
||||
replace={replace}
|
||||
scroll={scroll}
|
||||
shallow={shallow}
|
||||
passHref={passHref}
|
||||
locale={locale}
|
||||
>
|
||||
<a ref={ref} {...other} />
|
||||
</NextLink>
|
||||
);
|
||||
});
|
||||
|
||||
// A styled version of the Next.js Link component:
|
||||
// https://nextjs.org/docs/#with-link
|
||||
const Link = forwardRef(function Link(props: any, ref) {
|
||||
const {
|
||||
activeClassName = 'active',
|
||||
as: linkAs,
|
||||
className: classNameProps,
|
||||
href,
|
||||
noLinkStyle,
|
||||
role, // Link don't have roles.
|
||||
...other
|
||||
} = props;
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = typeof href === 'string' ? href : href.pathname;
|
||||
const className = clsx(classNameProps, {
|
||||
[activeClassName]: router.pathname === pathname && activeClassName,
|
||||
});
|
||||
|
||||
const isExternal =
|
||||
typeof href === 'string' && (href.indexOf('http') === 0 || href.indexOf('mailto:') === 0);
|
||||
|
||||
if (isExternal) {
|
||||
if (noLinkStyle) {
|
||||
return <Text variant='link' component='a' className={className} href={href} ref={ref} {...other} />;
|
||||
}
|
||||
|
||||
return <Text component='a' variant='link' href={href} ref={ref} {...other} />;
|
||||
}
|
||||
|
||||
if (noLinkStyle) {
|
||||
return <NextLinkComposed className={className} ref={ref} to={href} {...other} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
component={NextLinkComposed}
|
||||
variant='link'
|
||||
linkAs={linkAs}
|
||||
className={className}
|
||||
ref={ref}
|
||||
to={href}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
});
|
||||
import { NextLink as Link } from '@mantine/next';
|
||||
|
||||
export default Link;
|
||||
5
src/components/MutedText.tsx
Normal file
5
src/components/MutedText.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Text } from '@mantine/core';
|
||||
|
||||
export default function MutedText({ children, ...props }) {
|
||||
return <Text color='dimmed' size='xl' {...props}>{children}</Text>;
|
||||
}
|
||||
78
src/components/PasswordStrength.tsx
Normal file
78
src/components/PasswordStrength.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
// https://mantine.dev/core/password-input/
|
||||
|
||||
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 }) {
|
||||
return (
|
||||
<Text
|
||||
color={meets ? 'teal' : 'red'}
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
mt='sm'
|
||||
size='sm'
|
||||
>
|
||||
{meets ? <CheckIcon /> : <CrossIcon />} <Box ml='md'>{label}</Box>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const requirements = [
|
||||
{ re: /[0-9]/, label: 'Includes number' },
|
||||
{ re: /[a-z]/, label: 'Includes lowercase letter' },
|
||||
{ re: /[A-Z]/, label: 'Includes uppercase letter' },
|
||||
{ re: /[$&+,:;=?@#|'<>.^*()%!-]/, label: 'Includes special symbol' },
|
||||
];
|
||||
|
||||
function getStrength(password: string) {
|
||||
let multiplier = password.length > 7 ? 0 : 1;
|
||||
|
||||
requirements.forEach((requirement) => {
|
||||
if (!requirement.re.test(password)) {
|
||||
multiplier += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return Math.max(100 - (100 / (requirements.length + 1)) * multiplier, 10);
|
||||
}
|
||||
|
||||
export default function PasswordStrength({ value, setValue, setStrength, ...props }) {
|
||||
const [popoverOpened, setPopoverOpened] = useState(false);
|
||||
const checks = requirements.map((requirement, index) => (
|
||||
<PasswordRequirement key={index} label={requirement.label} meets={requirement.re.test(value)} />
|
||||
));
|
||||
|
||||
const strength = getStrength(value);
|
||||
setStrength(strength);
|
||||
const color = strength === 100 ? 'teal' : strength > 50 ? 'yellow' : 'red';
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={popoverOpened}
|
||||
position='top'
|
||||
width='target'
|
||||
withArrow
|
||||
trapFocus={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div
|
||||
onFocusCapture={() => setPopoverOpened(true)}
|
||||
onBlurCapture={() => setPopoverOpened(false)}
|
||||
>
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
description='A strong password should include letters in lower and uppercase, at least 1 number, at least 1 special symbol'
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown sx={{ }}>
|
||||
<Progress color={color} value={strength} size={7} mb='md' />
|
||||
<PasswordRequirement label='Includes at least 8 characters' meets={value.length > 7} />
|
||||
{checks}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
29
src/components/SmallTable.tsx
Normal file
29
src/components/SmallTable.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Box, Table } from '@mantine/core';
|
||||
import { randomId } from '@mantine/hooks';
|
||||
|
||||
export function SmallTable({ rows, columns }) {
|
||||
return (
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<Table highlightOnHover>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map(col => (
|
||||
<th key={randomId()}>{col.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<tr key={randomId()}>
|
||||
{columns.map(col => (
|
||||
<td key={randomId()}>
|
||||
{col.format ? col.format(row[col.id]) : row[col.id]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Text } from '@mantine/core';
|
||||
|
||||
export default function StatText({ children }) {
|
||||
return <Text color='gray' size='xl'>{children}</Text>;
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
// themes
|
||||
import dark_blue from 'lib/themes/dark_blue';
|
||||
import light_blue from 'lib/themes/light_blue';
|
||||
import dark from 'lib/themes/dark';
|
||||
import ayu_dark from 'lib/themes/ayu_dark';
|
||||
import ayu_mirage from 'lib/themes/ayu_mirage';
|
||||
import ayu_light from 'lib/themes/ayu_light';
|
||||
import nord from 'lib/themes/nord';
|
||||
import ayu_mirage from 'lib/themes/ayu_mirage';
|
||||
import dark from 'lib/themes/dark';
|
||||
import dark_blue from 'lib/themes/dark_blue';
|
||||
import dracula from 'lib/themes/dracula';
|
||||
import light_blue from 'lib/themes/light_blue';
|
||||
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 { useStoreSelector } from 'lib/redux/store';
|
||||
import { MantineProvider, MantineThemeOverride } from '@mantine/core';
|
||||
import { useColorScheme } from '@mantine/hooks';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import { NotificationsProvider } from '@mantine/notifications';
|
||||
import { useColorScheme } from '@mantine/hooks';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
|
||||
export const themes = {
|
||||
system: (colorScheme: 'dark' | 'light') => colorScheme === 'dark' ? dark_blue : light_blue,
|
||||
@@ -64,24 +64,34 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
theme={theme}
|
||||
styles={{
|
||||
AppShell: t => ({
|
||||
root: {
|
||||
backgroundColor: t.other.AppShell_backgroundColor,
|
||||
theme={{
|
||||
...theme,
|
||||
components: {
|
||||
AppShell: {
|
||||
styles: t => ({
|
||||
root: {
|
||||
backgroundColor: t.other.AppShell_backgroundColor,
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
Popover: {
|
||||
inner: {
|
||||
width: 200,
|
||||
NavLink: {
|
||||
styles: t => ({
|
||||
icon: {
|
||||
paddingLeft: t.spacing.sm,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
Accordion: {
|
||||
itemTitle: {
|
||||
border: 0,
|
||||
Modal: {
|
||||
defaultProps: {
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
overlayColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
|
||||
},
|
||||
},
|
||||
itemOpened: {
|
||||
border: 0,
|
||||
Popover: {
|
||||
defaultProps: {
|
||||
transition: 'pop',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
45
src/components/Type.tsx
Normal file
45
src/components/Type.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Group, Image, Text } from '@mantine/core';
|
||||
import { Prism } from '@mantine/prism';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AudioIcon, FileIcon, PlayIcon } from './icons';
|
||||
|
||||
function Placeholder({ text, Icon, ...props }) {
|
||||
return (
|
||||
<Image height={200} withPlaceholder placeholder={
|
||||
<Group>
|
||||
<Icon size={48} />
|
||||
<Text size='md'>{text}</Text>
|
||||
</Group>
|
||||
} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export default function Type({ file, popup = false, ...props }){
|
||||
const type = (file.type || file.mimetype).split('/')[0];
|
||||
const name = (file.name || file.file);
|
||||
|
||||
const [text, setText] = useState('');
|
||||
|
||||
if (type === 'text') {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetch('/r/' + name);
|
||||
const text = await res.text();
|
||||
|
||||
setText(text);
|
||||
})();
|
||||
}, []);
|
||||
}
|
||||
|
||||
return popup ? {
|
||||
'video': <video width='100%' autoPlay controls {...props} />,
|
||||
'image': <Image {...props} />,
|
||||
'audio': <audio autoPlay controls {...props} style={{ width: '100%' }}/>,
|
||||
'text': <Prism withLineNumbers language={name.split('.').pop()} {...props} style={{}} sx={{}}>{text}</Prism>,
|
||||
}[type] : {
|
||||
'video': <Placeholder Icon={PlayIcon} text={`Click to view video (${name})`} {...props} />,
|
||||
'image': <Image {...props} />,
|
||||
'audio': <Placeholder Icon={AudioIcon} text={`Click to view audio (${name})`} {...props}/>,
|
||||
'text': <Placeholder Icon={FileIcon} text={`Click to view text file (${name})`} {...props}/>,
|
||||
}[type];
|
||||
};
|
||||
24
src/components/dropzone/Dropzone.tsx
Normal file
24
src/components/dropzone/Dropzone.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
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} />
|
||||
|
||||
<Text size='xl' inline>
|
||||
Drag images here or click to select files
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<div style={{ pointerEvents: 'all' }}>
|
||||
{children}
|
||||
</div>
|
||||
</MantineDropzone>
|
||||
);
|
||||
}
|
||||
53
src/components/dropzone/DropzoneFile.tsx
Normal file
53
src/components/dropzone/DropzoneFile.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { Table, Tooltip, Badge, useMantineTheme } from '@mantine/core';
|
||||
import Type from 'components/Type';
|
||||
|
||||
export function FilePreview({ file }: { file: File }) {
|
||||
return (
|
||||
<Type
|
||||
file={file}
|
||||
autoPlay
|
||||
sx={{ maxWidth: '10vw', maxHeight: '100vh' }}
|
||||
style={{ maxWidth: '10vw', maxHeight: '100vh' }}
|
||||
src={URL.createObjectURL(file)}
|
||||
alt={file.name}
|
||||
popup
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FileDropzone({ file }: { file: File }) {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
position='top'
|
||||
label={
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<FilePreview file={file} />
|
||||
|
||||
<Table sx={{ color: theme.colorScheme === 'dark' ? 'white' : 'white' }} ml='md'>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>{file.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Type</td>
|
||||
<td>{file.type}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last Modified</td>
|
||||
<td>{new Date(file.lastModified).toLocaleString()}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Badge size='lg'>
|
||||
{file.name}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
5
src/components/icons/ActivityIcon.tsx
Normal file
5
src/components/icons/ActivityIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Activity } from 'react-feather';
|
||||
|
||||
export default function ActivityIcon({ ...props }) {
|
||||
return <Activity size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/AudioIcon.tsx
Normal file
5
src/components/icons/AudioIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Disc } from 'react-feather';
|
||||
|
||||
export default function AudioIcon({ ...props }) {
|
||||
return <Disc size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/CalendarIcon.tsx
Normal file
5
src/components/icons/CalendarIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Calendar } from 'react-feather';
|
||||
|
||||
export default function CalendarIcon({ ...props }) {
|
||||
return <Calendar size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/CheckIcon.tsx
Normal file
5
src/components/icons/CheckIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Check } from 'react-feather';
|
||||
|
||||
export default function CheckIcon({ ...props }) {
|
||||
return <Check size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/ClockIcon.tsx
Normal file
5
src/components/icons/ClockIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Clock } from 'react-feather';
|
||||
|
||||
export default function ClockIcon({ ...props }) {
|
||||
return <Clock size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/CopyIcon.tsx
Normal file
5
src/components/icons/CopyIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Copy } from 'react-feather';
|
||||
|
||||
export default function CopyIcon({ ...props }) {
|
||||
return <Copy size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/CrossIcon.tsx
Normal file
5
src/components/icons/CrossIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { X } from 'react-feather';
|
||||
|
||||
export default function CrossIcon({ ...props }) {
|
||||
return <X size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/DeleteIcon.tsx
Normal file
5
src/components/icons/DeleteIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Delete } from 'react-feather';
|
||||
|
||||
export default function DeleteIcon({ ...props }) {
|
||||
return <Delete size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/DownloadIcon.tsx
Normal file
5
src/components/icons/DownloadIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Download } from 'react-feather';
|
||||
|
||||
export default function DownloadIcon({ ...props }) {
|
||||
return <Download size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/EnterIcon.tsx
Normal file
5
src/components/icons/EnterIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { LogIn } from 'react-feather';
|
||||
|
||||
export default function EnterIcon({ ...props }) {
|
||||
return <LogIn size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/FileIcon.tsx
Normal file
5
src/components/icons/FileIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { File } from 'react-feather';
|
||||
|
||||
export default function FileIcon({ ...props }) {
|
||||
return <File size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/HashIcon.tsx
Normal file
5
src/components/icons/HashIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Hash } from 'react-feather';
|
||||
|
||||
export default function HashIcon({ ...props }) {
|
||||
return <Hash size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/HomeIcon.tsx
Normal file
5
src/components/icons/HomeIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Home } from 'react-feather';
|
||||
|
||||
export default function HomeIcon({ ...props }) {
|
||||
return <Home size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/ImageIcon.tsx
Normal file
5
src/components/icons/ImageIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Image as FeatherImage } from 'react-feather';
|
||||
|
||||
export default function ImageIcon({ ...props }) {
|
||||
return <FeatherImage size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/LinkIcon.tsx
Normal file
5
src/components/icons/LinkIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Link } from 'react-feather';
|
||||
|
||||
export default function LinkIcon({ ...props }) {
|
||||
return <Link size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/LogoutIcon.tsx
Normal file
5
src/components/icons/LogoutIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { LogOut } from 'react-feather';
|
||||
|
||||
export default function LogoutIcon({ ...props }) {
|
||||
return <LogOut size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/PencilIcon.tsx
Normal file
5
src/components/icons/PencilIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Edit2 } from 'react-feather';
|
||||
|
||||
export default function PencilIcon({ ...props }) {
|
||||
return <Edit2 size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/PlayIcon.tsx
Normal file
5
src/components/icons/PlayIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Play } from 'react-feather';
|
||||
|
||||
export default function PlayIcon({ ...props }) {
|
||||
return <Play size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/PlusIcon.tsx
Normal file
5
src/components/icons/PlusIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Plus } from 'react-feather';
|
||||
|
||||
export default function PlusIcon({ ...props }) {
|
||||
return <Plus size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/SettingsIcon.tsx
Normal file
5
src/components/icons/SettingsIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Settings } from 'react-feather';
|
||||
|
||||
export default function SettingsIcon({ ...props }) {
|
||||
return <Settings size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/StarIcon.tsx
Normal file
5
src/components/icons/StarIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Star } from 'react-feather';
|
||||
|
||||
export default function StarIcon({ ...props }) {
|
||||
return <Star size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/TagIcon.tsx
Normal file
5
src/components/icons/TagIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Tag } from 'react-feather';
|
||||
|
||||
export default function TagIcon({ ...props }) {
|
||||
return <Tag size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/TypeIcon.tsx
Normal file
5
src/components/icons/TypeIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Type } from 'react-feather';
|
||||
|
||||
export default function TypeIcon({ ...props }) {
|
||||
return <Type size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/UploadIcon.tsx
Normal file
5
src/components/icons/UploadIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Upload } from 'react-feather';
|
||||
|
||||
export default function UploadIcon({ ...props }) {
|
||||
return <Upload size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/UserIcon.tsx
Normal file
5
src/components/icons/UserIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { User } from 'react-feather';
|
||||
|
||||
export default function UserIcon({ ...props }) {
|
||||
return <User size={15} {...props} />;
|
||||
}
|
||||
5
src/components/icons/VideoIcon.tsx
Normal file
5
src/components/icons/VideoIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Video } from 'react-feather';
|
||||
|
||||
export default function VideoIcon({ ...props }) {
|
||||
return <Video size={15} {...props} />;
|
||||
}
|
||||
53
src/components/icons/index.tsx
Normal file
53
src/components/icons/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import ActivityIcon from './ActivityIcon';
|
||||
import CheckIcon from './CheckIcon';
|
||||
import CopyIcon from './CopyIcon';
|
||||
import CrossIcon from './CrossIcon';
|
||||
import DeleteIcon from './DeleteIcon';
|
||||
import FileIcon from './FileIcon';
|
||||
import HomeIcon from './HomeIcon';
|
||||
import LinkIcon from './LinkIcon';
|
||||
import LogoutIcon from './LogoutIcon';
|
||||
import PencilIcon from './PencilIcon';
|
||||
import SettingsIcon from './SettingsIcon';
|
||||
import TypeIcon from './TypeIcon';
|
||||
import UploadIcon from './UploadIcon';
|
||||
import UserIcon from './UserIcon';
|
||||
import EnterIcon from './EnterIcon';
|
||||
import PlusIcon from './PlusIcon';
|
||||
import ImageIcon from './ImageIcon';
|
||||
import StarIcon from './StarIcon';
|
||||
import AudioIcon from './AudioIcon';
|
||||
import VideoIcon from './VideoIcon';
|
||||
import PlayIcon from './PlayIcon';
|
||||
import CalendarIcon from './CalendarIcon';
|
||||
import HashIcon from './HashIcon';
|
||||
import TagIcon from './TagIcon';
|
||||
import ClockIcon from './ClockIcon';
|
||||
|
||||
export {
|
||||
ActivityIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
CrossIcon,
|
||||
DeleteIcon,
|
||||
FileIcon,
|
||||
HomeIcon,
|
||||
LinkIcon,
|
||||
LogoutIcon,
|
||||
PencilIcon,
|
||||
SettingsIcon,
|
||||
TypeIcon,
|
||||
UploadIcon,
|
||||
UserIcon,
|
||||
EnterIcon,
|
||||
PlusIcon,
|
||||
ImageIcon,
|
||||
StarIcon,
|
||||
AudioIcon,
|
||||
VideoIcon,
|
||||
PlayIcon,
|
||||
CalendarIcon,
|
||||
HashIcon,
|
||||
TagIcon,
|
||||
ClockIcon,
|
||||
};
|
||||
@@ -1,33 +1,19 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { SimpleGrid, Skeleton, Text, Title } from '@mantine/core';
|
||||
import { randomId, useClipboard } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import Card from 'components/Card';
|
||||
import ZiplineImage from 'components/Image';
|
||||
import File from 'components/File';
|
||||
import { CopyIcon, CrossIcon, DeleteIcon } from 'components/icons';
|
||||
import ImagesTable from 'components/ImagesTable';
|
||||
import Link from 'components/Link';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { bytesToRead } from 'lib/clientUtils';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import { Text, Skeleton, Title, SimpleGrid } from '@mantine/core';
|
||||
import { randomId, useClipboard } from '@mantine/hooks';
|
||||
import Link from 'components/Link';
|
||||
import { CopyIcon, Cross1Icon, TrashIcon } from '@modulz/radix-icons';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import StatText from 'components/StatText';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify';
|
||||
|
||||
export function bytesToRead(bytes: number) {
|
||||
if (isNaN(bytes)) return '0.0 B';
|
||||
if (bytes === Infinity) return '0.0 B';
|
||||
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let num = 0;
|
||||
|
||||
while (bytes > 1024) {
|
||||
bytes /= 1024;
|
||||
++num;
|
||||
}
|
||||
|
||||
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
|
||||
@@ -35,7 +21,6 @@ export default function Dashboard() {
|
||||
const [recent, setRecent] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const clipboard = useClipboard();
|
||||
const notif = useNotifications();
|
||||
|
||||
const updateImages = async () => {
|
||||
const imgs = await useFetch('/api/user/files');
|
||||
@@ -50,26 +35,26 @@ export default function Dashboard() {
|
||||
const res = await useFetch('/api/user/files', 'DELETE', { id: original.id });
|
||||
if (!res.error) {
|
||||
updateImages();
|
||||
notif.showNotification({
|
||||
showNotification({
|
||||
title: 'Image Deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <TrashIcon />,
|
||||
icon: <DeleteIcon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
showNotification({
|
||||
title: 'Failed to delete image',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
const copyImage = async ({ original }) => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${original.url}`);
|
||||
notif.showNotification({
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
@@ -83,11 +68,11 @@ export default function Dashboard() {
|
||||
useEffect(() => {
|
||||
updateImages();
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Welcome back {user?.username}</Title>
|
||||
<Text color='gray' sx={{ paddingBottom: 4 }}>You have <b>{images.length ? images.length : '...'}</b> files</Text>
|
||||
<Title>Welcome back, {user?.username}</Title>
|
||||
<MutedText size='md'>You have <b>{images.length ? images.length : '...'}</b> files</MutedText>
|
||||
|
||||
<Title>Recent Files</Title>
|
||||
<SimpleGrid
|
||||
@@ -98,16 +83,16 @@ export default function Dashboard() {
|
||||
]}
|
||||
>
|
||||
{recent.length ? recent.map(image => (
|
||||
<ZiplineImage key={randomId()} image={image} updateImages={updateImages} />
|
||||
)) : [1,2,3,4].map(x => (
|
||||
<File key={randomId()} image={image} updateImages={updateImages} />
|
||||
)) : [1, 2, 3, 4].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Title mt='md'>Stats</Title>
|
||||
<Text>View more stats here <Link href='/dashboard/stats'>here</Link>.</Text>
|
||||
<MutedText size='md'>View more stats here <Link href='/dashboard/stats'>here</Link>.</MutedText>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
@@ -116,20 +101,22 @@ export default function Dashboard() {
|
||||
]}
|
||||
>
|
||||
<Card name='Size' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.size : <Skeleton height={8} />}</StatText>
|
||||
<MutedText>{stats ? stats.size : <Skeleton height={8} />}</MutedText>
|
||||
<Title order={2}>Average Size</Title>
|
||||
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</StatText>
|
||||
<MutedText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</MutedText>
|
||||
</Card>
|
||||
<Card name='Images' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.count : <Skeleton height={8} />}</StatText>
|
||||
<MutedText>{stats ? stats.count : <Skeleton height={8} />}</MutedText>
|
||||
<Title order={2}>Views</Title>
|
||||
<StatText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</StatText>
|
||||
<MutedText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</MutedText>
|
||||
</Card>
|
||||
<Card name='Users' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.count_users : <Skeleton height={8} />}</StatText>
|
||||
<MutedText>{stats ? stats.count_users : <Skeleton height={8} />}</MutedText>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Title mt='md'>Files</Title>
|
||||
<MutedText size='md'>View your gallery <Link href='/dashboard/files'>here</Link>.</MutedText>
|
||||
<ImagesTable
|
||||
columns={[
|
||||
{ accessor: 'file', Header: 'Name', minWidth: 170, align: 'inherit' as Aligns },
|
||||
@@ -141,34 +128,6 @@ export default function Dashboard() {
|
||||
copyImage={copyImage}
|
||||
viewImage={viewImage}
|
||||
/>
|
||||
|
||||
{/* <Title mt='md'>Files</Title>
|
||||
<Text>View previews of your files in the <Link href='/dashboard/files'>browser</Link>.</Text>
|
||||
<ReactTable
|
||||
columns={[
|
||||
{ accessor: 'file', Header: 'Name', minWidth: 170, align: 'inherit' as Aligns },
|
||||
{ accessor: 'mimetype', Header: 'Type', minWidth: 100, align: 'inherit' as Aligns },
|
||||
{ accessor: 'created_at', Header: 'Date' },
|
||||
]}
|
||||
data={images}
|
||||
pagination
|
||||
/>
|
||||
<Card name='Files per User' mt={22}>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'username', name: 'Name' },
|
||||
{ id: 'count', name: 'Files' },
|
||||
]}
|
||||
rows={stats ? stats.count_by_user : []} />
|
||||
</Card>
|
||||
<Card name='Types' mt={22}>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'mimetype', name: 'Type' },
|
||||
{ id: 'count', name: 'Count' },
|
||||
]}
|
||||
rows={stats ? stats.types_count : []} />
|
||||
</Card> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import ZiplineImage from 'components/Image';
|
||||
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Skeleton, Title } from '@mantine/core';
|
||||
import File from 'components/File';
|
||||
import { PlusIcon } from 'components/icons';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { Box, Accordion, Pagination, Title, SimpleGrid, Skeleton, Group, ActionIcon } from '@mantine/core';
|
||||
import { PlusIcon } from '@modulz/radix-icons';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function Files() {
|
||||
const [pages, setPages] = useState([]);
|
||||
@@ -27,48 +26,48 @@ export default function Files() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group>
|
||||
<Title sx={{ marginBottom: 12 }}>Files</Title>
|
||||
<Group mb='md'>
|
||||
<Title>Files</Title>
|
||||
<Link href='/dashboard/upload' passHref>
|
||||
<ActionIcon component='a' variant='filled' color='primary'><PlusIcon/></ActionIcon>
|
||||
</Link>
|
||||
</Group>
|
||||
<Accordion
|
||||
offsetIcon={false}
|
||||
sx={t => ({
|
||||
marginTop: 2,
|
||||
border: '1px solid',
|
||||
marginBottom: 12,
|
||||
borderColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0] ,
|
||||
})}
|
||||
>
|
||||
<Accordion.Item label={<Title>Favorite Files</Title>}>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{favoritePages.length ? favoritePages[(favoritePage - 1) ?? 0].map(image => (
|
||||
<div key={image.id}>
|
||||
<ZiplineImage image={image} updateImages={() => updatePages(true)} />
|
||||
</div>
|
||||
)) : null}
|
||||
</SimpleGrid>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Pagination total={favoritePages.length} page={favoritePage} onChange={setFavoritePage}/>
|
||||
</Box>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
{favoritePages.length ? (
|
||||
<Accordion
|
||||
variant='contained'
|
||||
mb='sm'
|
||||
>
|
||||
<Accordion.Item value='favorite'>
|
||||
<Accordion.Control>Favorite Files</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{favoritePages.length ? favoritePages[(favoritePage - 1) ?? 0].map(image => (
|
||||
<div key={image.id}>
|
||||
<File image={image} updateImages={() => updatePages(true)} />
|
||||
</div>
|
||||
)) : null}
|
||||
</SimpleGrid>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Pagination total={favoritePages.length} page={favoritePage} onChange={setFavoritePage}/>
|
||||
</Box>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
) : null}
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
@@ -78,7 +77,7 @@ export default function Files() {
|
||||
>
|
||||
{pages.length ? pages[(page - 1) ?? 0].map(image => (
|
||||
<div key={image.id}>
|
||||
<ZiplineImage image={image} updateImages={() => updatePages(true)} />
|
||||
<File image={image} updateImages={() => updatePages(true)} />
|
||||
</div>
|
||||
)) : [1,2,3,4].map(x => (
|
||||
<div key={x}>
|
||||
|
||||
199
src/components/pages/Invites.tsx
Normal file
199
src/components/pages/Invites.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { ActionIcon, Avatar, Button, Card, Group, Modal, Select, SimpleGrid, Skeleton, Stack, Switch, TextInput, Title } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { useForm } from '@mantine/form';
|
||||
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 { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const expires = [
|
||||
'30m',
|
||||
'1h',
|
||||
'6h',
|
||||
'12h',
|
||||
'1d',
|
||||
'3d',
|
||||
'5d',
|
||||
'7d',
|
||||
'never',
|
||||
];
|
||||
|
||||
function CreateInviteModal({ open, setOpen, updateInvites }) {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
expires: '30m',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async values => {
|
||||
if (!expires.includes(values.expires)) return form.setFieldError('expires', 'Invalid expiration');
|
||||
const expires_at = values.expires === 'never' ? null : new Date({
|
||||
'30m': Date.now() + 30 * 60 * 1000,
|
||||
'1h': Date.now() + 60 * 60 * 1000,
|
||||
'6h': Date.now() + 6 * 60 * 60 * 1000,
|
||||
'12h': Date.now() + 12 * 60 * 60 * 1000,
|
||||
'1d': Date.now() + 24 * 60 * 60 * 1000,
|
||||
'5d': Date.now() + 5 * 24 * 60 * 60 * 1000,
|
||||
'7d': Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
}[values.expires]);
|
||||
|
||||
setOpen(false);
|
||||
|
||||
const res = await useFetch('/api/auth/invite', 'POST', {
|
||||
expires_at,
|
||||
});
|
||||
|
||||
if (res.error) {
|
||||
showNotification({
|
||||
title: 'Failed to create invite',
|
||||
message: res.error,
|
||||
icon: <CrossIcon />,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Created invite',
|
||||
message: '',
|
||||
icon: <TagIcon />,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
|
||||
updateInvites();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Title>Create Invite</Title>}
|
||||
>
|
||||
<form onSubmit={form.onSubmit(v => onSubmit(v))}>
|
||||
<Select
|
||||
label='Expires'
|
||||
id='expires'
|
||||
{...form.getInputProps('expires')}
|
||||
data={[
|
||||
{ value: '30m', label: '30 minutes' },
|
||||
{ value: '1h', label: '1 hour' },
|
||||
{ value: '6h', label: '6 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: 'never', label: 'Never' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Group position='right' mt={22}>
|
||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button type='submit'>Create</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Users() {
|
||||
const router = useRouter();
|
||||
const modals = useModals();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const [invites, setInvites] = useState([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const openDeleteModal = invite => modals.openConfirmModal({
|
||||
title: `Delete ${invite.code}?`,
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
labels: { confirm: 'Yes', cancel: 'No' },
|
||||
onConfirm: async () => {
|
||||
const res = await useFetch(`/api/auth/invite?code=${invite.code}`, 'DELETE');
|
||||
if (res.error) {
|
||||
showNotification({
|
||||
title: 'Failed to delete invite ${invite.code}',
|
||||
message: res.error,
|
||||
icon: <CrossIcon />,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: `Deleted invite ${invite.code}`,
|
||||
message: '',
|
||||
icon: <DeleteIcon />,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
|
||||
updateInvites();
|
||||
},
|
||||
});
|
||||
|
||||
const handleCopy = async invite => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}/invite/${invite.code}`);
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const updateInvites = async () => {
|
||||
const us = await useFetch('/api/auth/invite');
|
||||
if (!us.error) {
|
||||
setInvites(us);
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateInvites();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateInviteModal open={open} setOpen={setOpen} updateInvites={updateInvites} />
|
||||
<Group mb='md'>
|
||||
<Title>Invites</Title>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon /></ActionIcon>
|
||||
</Group>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{invites.length ? invites.map(invite => (
|
||||
<Card key={invite.id} sx={{ maxWidth: '100%' }}>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Avatar size='lg' color={invite.used ? 'dark' : 'primary'}>{invite.id}</Avatar>
|
||||
<Stack spacing={0}>
|
||||
<Title>{invite.code}{invite.used && <> (Used)</>}</Title>
|
||||
<MutedText size='sm'>Created: {new Date(invite.created_at).toLocaleString()}</MutedText>
|
||||
<MutedText size='sm'>Expires: {invite.expires_at ? new Date(invite.expires_at).toLocaleString() : 'Never'}</MutedText>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
|
||||
<CopyIcon />
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)) : [1, 2, 3].map(x => (
|
||||
<Skeleton key={x} width='100%' height={100} radius='sm' />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +1,87 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import Link from 'components/Link';
|
||||
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
import { randomId, useForm, useInterval } from '@mantine/hooks';
|
||||
import { Card, Tooltip, TextInput, Button, Text, Title, Group, ColorInput, MultiSelect, Space, Box, Table } from '@mantine/core';
|
||||
import { DownloadIcon, Cross1Icon, TrashIcon } from '@modulz/radix-icons';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { Box, Button, Card, ColorInput, Group, MultiSelect, Space, Text, TextInput, PasswordInput, Title, Tooltip, FileInput, Image } from '@mantine/core';
|
||||
import { randomId, useInterval } from '@mantine/hooks';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useModals } from '@mantine/modals';
|
||||
|
||||
function VarsTooltip({ children }) {
|
||||
return (
|
||||
<Tooltip position='top' placement='center' color='' label={
|
||||
<>
|
||||
<Text><b>{'{image.file}'}</b> - file name</Text>
|
||||
<Text><b>{'{image.mimetype}'}</b> - mimetype</Text>
|
||||
<Text><b>{'{image.id}'}</b> - id of the image</Text>
|
||||
<Text><b>{'{user.name}'}</b> - your username</Text>
|
||||
visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables
|
||||
</>
|
||||
}>
|
||||
{children}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import { CrossIcon, DeleteIcon, SettingsIcon } from 'components/icons';
|
||||
import DownloadIcon from 'components/icons/DownloadIcon';
|
||||
import Link from 'components/Link';
|
||||
import { SmallTable } from 'components/SmallTable';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { bytesToRead } from 'lib/clientUtils';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
|
||||
import { useEffect, useState } from 'react';
|
||||
import MutedText from 'components/MutedText';
|
||||
|
||||
function ExportDataTooltip({ children }) {
|
||||
return <Tooltip position='top' placement='center' color='' label='After clicking, if you have a lot of files the export can take a while to complete. A list of previous exports will be below to download.'>{children}</Tooltip>;
|
||||
}
|
||||
|
||||
function ExportTable({ rows, columns }) {
|
||||
return (
|
||||
<Box sx={{ pt: 1 }} >
|
||||
<Table highlightOnHover>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map(col => (
|
||||
<th key={randomId()}>{col.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<tr key={randomId()}>
|
||||
{columns.map(col => (
|
||||
<td key={randomId()}>
|
||||
{col.format ? col.format(row[col.id]) : row[col.id]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
return <Tooltip position='top' color='' label='After clicking, if you have a lot of files the export can take a while to complete. A list of previous exports will be below to download.'>{children}</Tooltip>;
|
||||
}
|
||||
|
||||
export default function Manage() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
const dispatch = useStoreDispatch();
|
||||
const notif = useNotifications();
|
||||
const modals = useModals();
|
||||
|
||||
const [exports, setExports] = useState([]);
|
||||
const [domains, setDomains] = useState(user.domains ?? []);
|
||||
const [file, setFile] = useState<File>(null);
|
||||
const [fileDataURL, setFileDataURL] = useState(user.avatar ?? null);
|
||||
|
||||
const getDataURL = (f: File): Promise<string> => {
|
||||
return new Promise((res, rej) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.addEventListener('load', () => {
|
||||
res(reader.result as string);
|
||||
});
|
||||
|
||||
reader.addEventListener('error', () => {
|
||||
rej(reader.error);
|
||||
});
|
||||
|
||||
reader.readAsDataURL(f);
|
||||
});
|
||||
};
|
||||
|
||||
const handleAvatarChange = async (file: File) => {
|
||||
setFile(file);
|
||||
|
||||
setFileDataURL(await getDataURL(file));
|
||||
};
|
||||
|
||||
const saveAvatar = async () => {
|
||||
const dataURL = await getDataURL(file);
|
||||
|
||||
showNotification({
|
||||
id: 'update-user',
|
||||
title: 'Updating user...',
|
||||
message: '',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
const newUser = await useFetch('/api/user', 'PATCH', {
|
||||
avatar: dataURL,
|
||||
});
|
||||
|
||||
if (newUser.error) {
|
||||
updateNotification({
|
||||
id: 'update-user',
|
||||
title: 'Couldn\'t save user',
|
||||
message: newUser.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
} else {
|
||||
dispatch(updateUser(newUser));
|
||||
updateNotification({
|
||||
id: 'update-user',
|
||||
title: 'Saved User',
|
||||
message: '',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const genShareX = (withEmbed: boolean = false, withZws: boolean = false) => {
|
||||
const config = {
|
||||
@@ -112,7 +129,8 @@ export default function Manage() {
|
||||
|
||||
if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
|
||||
|
||||
const id = notif.showNotification({
|
||||
showNotification({
|
||||
id: 'update-user',
|
||||
title: 'Updating user...',
|
||||
message: '',
|
||||
loading: true,
|
||||
@@ -132,7 +150,8 @@ export default function Manage() {
|
||||
|
||||
if (newUser.error) {
|
||||
if (newUser.invalidDomains) {
|
||||
notif.updateNotification(id, {
|
||||
updateNotification({
|
||||
id: 'update-user',
|
||||
message: <>
|
||||
<Text mt='xs'>The following domains are invalid:</Text>
|
||||
{newUser.invalidDomains.map(err => (
|
||||
@@ -143,18 +162,20 @@ export default function Manage() {
|
||||
))}
|
||||
</>,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
notif.updateNotification(id, {
|
||||
updateNotification({
|
||||
id: 'update-user',
|
||||
title: 'Couldn\'t save user',
|
||||
message: newUser.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
} else {
|
||||
dispatch(updateUser(newUser));
|
||||
notif.updateNotification(id, {
|
||||
updateNotification({
|
||||
id: 'update-user',
|
||||
title: 'Saved User',
|
||||
message: '',
|
||||
});
|
||||
@@ -164,7 +185,7 @@ export default function Manage() {
|
||||
const exportData = async () => {
|
||||
const res = await useFetch('/api/user/export', 'POST');
|
||||
if (res.url) {
|
||||
notif.showNotification({
|
||||
showNotification({
|
||||
title: 'Export started...',
|
||||
loading: true,
|
||||
message: 'If you have a lot of files, the export may take a while. The list of exports will be updated every 30s.',
|
||||
@@ -176,8 +197,9 @@ export default function Manage() {
|
||||
const res = await useFetch('/api/user/export');
|
||||
|
||||
setExports(res.exports.map(s => ({
|
||||
date: new Date(Number(s.split('_')[3].slice(0, -4))),
|
||||
full: s,
|
||||
date: new Date(Number(s.name.split('_')[3].slice(0, -4))),
|
||||
size: s.size,
|
||||
full: s.name,
|
||||
})).sort((a, b) => a.date.getTime() - b.date.getTime()));
|
||||
};
|
||||
|
||||
@@ -187,18 +209,18 @@ export default function Manage() {
|
||||
});
|
||||
|
||||
if (!res.count) {
|
||||
notif.showNotification({
|
||||
showNotification({
|
||||
title: 'Couldn\'t delete files',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
showNotification({
|
||||
title: 'Deleted files',
|
||||
message: `${res.count} files deleted`,
|
||||
color: 'green',
|
||||
icon: <TrashIcon />,
|
||||
icon: <DeleteIcon />,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -206,14 +228,10 @@ export default function Manage() {
|
||||
const openDeleteModal = () => modals.openConfirmModal({
|
||||
title: 'Are you sure you want to delete all of your images?',
|
||||
closeOnConfirm: false,
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
labels: { confirm: 'Yes', cancel: 'No' },
|
||||
onConfirm: () => {
|
||||
modals.openConfirmModal({
|
||||
title: 'Are you really sure?',
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
labels: { confirm: 'Yes', cancel: 'No' },
|
||||
onConfirm: () => {
|
||||
handleDelete();
|
||||
@@ -226,7 +244,6 @@ export default function Manage() {
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const interval = useInterval(() => getExports(), 30000);
|
||||
useEffect(() => {
|
||||
getExports();
|
||||
@@ -236,12 +253,10 @@ export default function Manage() {
|
||||
return (
|
||||
<>
|
||||
<Title>Manage User</Title>
|
||||
<VarsTooltip>
|
||||
<Text color='gray'>Want to use variables in embed text? Hover on this or visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables</Text>
|
||||
</VarsTooltip>
|
||||
<MutedText size='md'>Want to use variables in embed text? Visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for variables</MutedText>
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<TextInput id='username' label='Username' {...form.getInputProps('username')} />
|
||||
<TextInput id='password' label='Password' type='password' {...form.getInputProps('password')} />
|
||||
<PasswordInput id='password' label='Password' description='Leave blank to keep your old password' {...form.getInputProps('password')} />
|
||||
<TextInput id='embedTitle' label='Embed Title' {...form.getInputProps('embedTitle')} />
|
||||
<ColorInput id='embedColor' label='Embed Color' {...form.getInputProps('embedColor')} />
|
||||
<TextInput id='embedSiteName' label='Embed Site Name' {...form.getInputProps('embedSiteName')} />
|
||||
@@ -258,32 +273,67 @@ export default function Manage() {
|
||||
{...form.getInputProps('domains')}
|
||||
/>
|
||||
|
||||
<Group position='right' sx={{ paddingTop: 12 }}>
|
||||
<Group position='right' mt='md'>
|
||||
<Button
|
||||
type='submit'
|
||||
>Save User</Button>
|
||||
</Group>
|
||||
</form>
|
||||
|
||||
<Title sx={{ paddingTop: 12 }}>Manage Data</Title>
|
||||
<Text color='gray' sx={{ paddingBottom: 12 }}>Delete, or export your data into a zip file.</Text>
|
||||
<Box mb='md'>
|
||||
<Title>Avatar</Title>
|
||||
<FileInput id='file' description='Add a custom avatar or leave blank for none' accept='image/png,image/jpeg,image/gif' value={file} onChange={handleAvatarChange} />
|
||||
<Card mt='md'>
|
||||
<Text>Preview:</Text>
|
||||
<Button
|
||||
leftIcon={fileDataURL ? <Image src={fileDataURL} height={32} radius='md' /> : <SettingsIcon />}
|
||||
sx={t => ({
|
||||
backgroundColor: '#00000000',
|
||||
'&:hover': {
|
||||
backgroundColor: t.other.hover,
|
||||
},
|
||||
})}
|
||||
size='xl'
|
||||
p='sm'
|
||||
>
|
||||
{user.username}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Group position='right' mt='md'>
|
||||
<Button onClick={() => { setFile(null); setFileDataURL(null); }}>Reset</Button>
|
||||
<Button onClick={saveAvatar} >Save Avatar</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Box mb='md'>
|
||||
<Title>Manage Data</Title>
|
||||
<MutedText size='md'>Delete, or export your data into a zip file.</MutedText>
|
||||
</Box>
|
||||
|
||||
<Group>
|
||||
<Button onClick={openDeleteModal} rightIcon={<TrashIcon />}>Delete All Data</Button>
|
||||
<Button onClick={openDeleteModal} rightIcon={<DeleteIcon />}>Delete All Data</Button>
|
||||
<ExportDataTooltip><Button onClick={exportData} rightIcon={<DownloadIcon />}>Export Data</Button></ExportDataTooltip>
|
||||
</Group>
|
||||
<Card mt={22}>
|
||||
<ExportTable
|
||||
columns={[
|
||||
{ id: 'name', name: 'Name' },
|
||||
{ id: 'date', name: 'Date' },
|
||||
]}
|
||||
rows={exports ? exports.map((x, i) => ({
|
||||
name: <Link href={'/api/user/export?name=' + x.full}>Export {i + 1}</Link>,
|
||||
date: x.date.toLocaleString(),
|
||||
})) : []} />
|
||||
{exports && exports.length ? (
|
||||
<SmallTable
|
||||
columns={[
|
||||
{ id: 'name', name: 'Name' },
|
||||
{ id: 'date', name: 'Date' },
|
||||
{ id: 'size', name: 'Size' },
|
||||
]}
|
||||
rows={exports ? exports.map((x, i) => ({
|
||||
name: <Link href={'/api/user/export?name=' + x.full}>Export {i + 1}</Link>,
|
||||
date: x.date.toLocaleString(),
|
||||
size: bytesToRead(x.size),
|
||||
})) : []} />
|
||||
) : (
|
||||
<Text>No exports yet</Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Title sx={{ paddingTop: 12, paddingBottom: 12 }}>ShareX Config</Title>
|
||||
<Title my='md'>ShareX Config</Title>
|
||||
<Group>
|
||||
<Button onClick={() => genShareX(false)} rightIcon={<DownloadIcon />}>ShareX Config</Button>
|
||||
<Button onClick={() => genShareX(true)} rightIcon={<DownloadIcon />}>ShareX Config with Embed</Button>
|
||||
|
||||
@@ -1,52 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { SimpleGrid, Skeleton, Title } from '@mantine/core';
|
||||
import Card from 'components/Card';
|
||||
import StatText from 'components/StatText';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { SmallTable } from 'components/SmallTable';
|
||||
import { bytesToRead } from 'lib/clientUtils';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import { Box, Text, Table, Skeleton, Title, SimpleGrid } from '@mantine/core';
|
||||
import { randomId } from '@mantine/hooks';
|
||||
|
||||
export function bytesToRead(bytes: number) {
|
||||
if (isNaN(bytes)) return '0.0 B';
|
||||
if (bytes === Infinity) return '0.0 B';
|
||||
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let num = 0;
|
||||
|
||||
while (bytes > 1024) {
|
||||
bytes /= 1024;
|
||||
++num;
|
||||
}
|
||||
|
||||
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||
}
|
||||
|
||||
function StatTable({ rows, columns }) {
|
||||
return (
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<Table highlightOnHover>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map(col => (
|
||||
<th key={randomId()}>{col.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<tr key={randomId()}>
|
||||
{columns.map(col => (
|
||||
<td key={randomId()}>
|
||||
{col.format ? col.format(row[col.id]) : row[col.id]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function Stats() {
|
||||
const [stats, setStats] = useState(null);
|
||||
@@ -62,7 +20,7 @@ export default function Stats() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Stats</Title>
|
||||
<Title mb='md'>Stats</Title>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
@@ -71,30 +29,32 @@ export default function Stats() {
|
||||
]}
|
||||
>
|
||||
<Card name='Size' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.size : <Skeleton height={8} />}</StatText>
|
||||
<MutedText>{stats ? stats.size : <Skeleton height={8} />}</MutedText>
|
||||
<Title order={2}>Average Size</Title>
|
||||
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</StatText>
|
||||
<MutedText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</MutedText>
|
||||
</Card>
|
||||
<Card name='Images' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.count : <Skeleton height={8} />}</StatText>
|
||||
<MutedText>{stats ? stats.count : <Skeleton height={8} />}</MutedText>
|
||||
<Title order={2}>Views</Title>
|
||||
<StatText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</StatText>
|
||||
<MutedText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</MutedText>
|
||||
</Card>
|
||||
<Card name='Users' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.count_users : <Skeleton height={8} />}</StatText>
|
||||
<MutedText>{stats ? stats.count_users : <Skeleton height={8} />}</MutedText>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Card name='Files per User' mt={22}>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'username', name: 'Name' },
|
||||
{ id: 'count', name: 'Files' },
|
||||
]}
|
||||
rows={stats ? stats.count_by_user : []} />
|
||||
</Card>
|
||||
{stats && stats.count_by_user.length ? (
|
||||
<Card name='Files per User' mt={22}>
|
||||
<SmallTable
|
||||
columns={[
|
||||
{ id: 'username', name: 'Name' },
|
||||
{ id: 'count', name: 'Files' },
|
||||
]}
|
||||
rows={stats ? stats.count_by_user : []} />
|
||||
</Card>
|
||||
) : null}
|
||||
<Card name='Types' mt={22}>
|
||||
<StatTable
|
||||
<SmallTable
|
||||
columns={[
|
||||
{ id: 'mimetype', name: 'Type' },
|
||||
{ id: 'count', name: 'Count' },
|
||||
|
||||
@@ -1,90 +1,59 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import Link from 'components/Link';
|
||||
import { Image, Button, Group, Popover, Progress, Text, useMantineTheme, Tooltip, Stack, Table } from '@mantine/core';
|
||||
import { ImageIcon, UploadIcon, CrossCircledIcon } from '@modulz/radix-icons';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { Button, Collapse, Group, Progress, Select, Title } 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 { useStoreSelector } from 'lib/redux/store';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function ImageUploadIcon({ status, ...props }) {
|
||||
if (status.accepted) {
|
||||
return <UploadIcon {...props} />;
|
||||
}
|
||||
|
||||
if (status.rejected) {
|
||||
return <CrossCircledIcon {...props} />;
|
||||
}
|
||||
|
||||
return <ImageIcon {...props} />;
|
||||
}
|
||||
|
||||
function getIconColor(status, theme) {
|
||||
return status.accepted
|
||||
? theme.colors[theme.primaryColor][6]
|
||||
: status.rejected
|
||||
? theme.colors.red[6]
|
||||
: theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[0]
|
||||
: theme.black;
|
||||
}
|
||||
|
||||
function ImageDropzone({ file }: { file: File }) {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
position='top'
|
||||
placement='center'
|
||||
color={theme.colorScheme === 'dark' ? 'dark' : undefined}
|
||||
styles={{
|
||||
body: {
|
||||
color: 'white',
|
||||
},
|
||||
}}
|
||||
label={
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Image src={URL.createObjectURL(file)} alt={file.name} sx={{ maxWidth: '10vw', maxHeight: '100vh' }} mr='md' />
|
||||
<Table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>{file.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Type</td>
|
||||
<td>{file.type}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last Modified</td>
|
||||
<td>{new Date(file.lastModified).toLocaleString()}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Text weight='bold'>{file.name}</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const expires = [
|
||||
'5min',
|
||||
'10min',
|
||||
'15min',
|
||||
'30min',
|
||||
'1h',
|
||||
'2h',
|
||||
'3h',
|
||||
'4h',
|
||||
'5h',
|
||||
'6h',
|
||||
'8h',
|
||||
'12h',
|
||||
'1d',
|
||||
'3d',
|
||||
'5d',
|
||||
'7d',
|
||||
'1w',
|
||||
'1.5w',
|
||||
'2w',
|
||||
'3w',
|
||||
'1m',
|
||||
'1.5m',
|
||||
'2m',
|
||||
'3m',
|
||||
'6m',
|
||||
'8m',
|
||||
'1y',
|
||||
'never',
|
||||
];
|
||||
|
||||
export default function Upload() {
|
||||
const theme = useMantineTheme();
|
||||
const notif = useNotifications();
|
||||
const clipboard = useClipboard();
|
||||
const user = useStoreSelector(state => state.user);
|
||||
|
||||
const [files, setFiles] = useState([]);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expires, setExpires] = useState('never');
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('paste', (e: ClipboardEvent) => {
|
||||
const item = Array.from(e.clipboardData.items).find(x => /^image/.test(x.type));
|
||||
const blob = item.getAsFile();
|
||||
setFiles([...files, new File([blob], blob.name, { type: blob.type })]);
|
||||
notif.showNotification({
|
||||
const file = item.getAsFile();
|
||||
setFiles([...files, file]);
|
||||
showNotification({
|
||||
title: 'Image imported from clipboard',
|
||||
message: '',
|
||||
});
|
||||
@@ -92,11 +61,43 @@ export default function Upload() {
|
||||
});
|
||||
|
||||
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();
|
||||
for (let i = 0; i !== files.length; ++i) body.append('file', files[i]);
|
||||
|
||||
const id = notif.showNotification({
|
||||
showNotification({
|
||||
id: 'upload',
|
||||
title: 'Uploading Images...',
|
||||
message: '',
|
||||
loading: true,
|
||||
@@ -113,9 +114,11 @@ export default function Upload() {
|
||||
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) {
|
||||
notif.updateNotification(id, {
|
||||
updateNotification({
|
||||
id: 'upload',
|
||||
title: 'Upload Successful',
|
||||
message: <>Copied first image to clipboard! <br />{json.files.map(x => (<Link key={x} href={x}>{x}<br /></Link>))}</>,
|
||||
color: 'green',
|
||||
@@ -124,11 +127,12 @@ export default function Upload() {
|
||||
clipboard.copy(json.files[0]);
|
||||
setFiles([]);
|
||||
} else {
|
||||
notif.updateNotification(id, {
|
||||
updateNotification({
|
||||
id: 'upload',
|
||||
title: 'Upload Failed',
|
||||
message: json.error,
|
||||
color: 'red',
|
||||
icon: <CrossCircledIcon />,
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
setProgress(0);
|
||||
@@ -136,40 +140,62 @@ export default function Upload() {
|
||||
|
||||
req.open('POST', '/api/upload');
|
||||
req.setRequestHeader('Authorization', user.token);
|
||||
expires !== 'never' && req.setRequestHeader('Expires-At', expires_at.toISOString());
|
||||
|
||||
req.send(body);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropzone onDrop={(f) => setFiles([...files, ...f])}>
|
||||
{status => (
|
||||
<>
|
||||
<Group position='center' spacing='xl' style={{ minHeight: 220, pointerEvents: 'none' }}>
|
||||
<ImageUploadIcon
|
||||
status={status}
|
||||
style={{ width: 80, height: 80, color: getIconColor(status, theme) }}
|
||||
/>
|
||||
<Title mb='md'>Upload Files</Title>
|
||||
|
||||
<div>
|
||||
<Text size='xl' inline>
|
||||
Drag images here or click to select files
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
|
||||
</>
|
||||
)}
|
||||
<Dropzone loading={loading} onDrop={(f) => setFiles([...files, ...f])}>
|
||||
<Group position='center' spacing='md'>
|
||||
{files.map(file => (<FileDropzone key={randomId()} file={file} />))}
|
||||
</Group>
|
||||
</Dropzone>
|
||||
|
||||
<Group position='center' spacing='xl' mt={12}>
|
||||
{files.map(file => (<ImageDropzone key={randomId()} file={file} />))}
|
||||
</Group>
|
||||
<Collapse in={progress !== 0}>
|
||||
{progress !== 0 && <Progress mt='md' value={progress} animate />}
|
||||
</Collapse>
|
||||
|
||||
{progress !== 0 && <Progress sx={{ marginTop: 12 }} value={progress} />}
|
||||
|
||||
<Group position='right'>
|
||||
<Button leftIcon={<UploadIcon />} mt={12} onClick={handleUpload}>Upload</Button>
|
||||
<Group position='right' mt='md'>
|
||||
<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' },
|
||||
]}
|
||||
/>
|
||||
<Button leftIcon={<UploadIcon />} onClick={handleUpload} disabled={files.length === 0 ? true : false}>Upload</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
|
||||
72
src/components/pages/UploadText.tsx
Normal file
72
src/components/pages/UploadText.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Button, Group, Select, Title } from '@mantine/core';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import CodeInput from 'components/CodeInput';
|
||||
import { TypeIcon, UploadIcon } from 'components/icons';
|
||||
import Link from 'components/Link';
|
||||
import exts from 'lib/exts';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Upload() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
const [lang, setLang] = useState('txt');
|
||||
|
||||
const handleUpload = async () => {
|
||||
const file = new File([value], 'text.' + lang);
|
||||
|
||||
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');
|
||||
|
||||
req.send(body);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title mb='md'>Upload Text</Title>
|
||||
|
||||
<CodeInput
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
/>
|
||||
|
||||
<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 />}
|
||||
/>
|
||||
<Button leftIcon={<UploadIcon />} onClick={handleUpload} disabled={value.trim().length === 0 ? true : false}>Upload</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { ActionIcon, Button, Card, Group, Modal, SimpleGrid, Skeleton, TextInput, Title } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { CopyIcon, CrossIcon, DeleteIcon, LinkIcon, PlusIcon } from 'components/icons';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import { useClipboard, useForm } from '@mantine/hooks';
|
||||
import { CopyIcon, Cross1Icon, Link1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { Modal, Title, Group, Button, Box, Card, TextInput, ActionIcon, SimpleGrid, Skeleton } from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function Urls() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
const notif = useNotifications();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const [urls, setURLS] = useState([]);
|
||||
@@ -24,17 +23,17 @@ export default function Urls() {
|
||||
const deleteURL = async u => {
|
||||
const url = await useFetch('/api/user/urls', 'DELETE', { id: u.id });
|
||||
if (url.error) {
|
||||
notif.showNotification({
|
||||
showNotification({
|
||||
title: 'Failed to delete URL',
|
||||
message: url.error,
|
||||
icon: <TrashIcon />,
|
||||
icon: <DeleteIcon />,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
showNotification({
|
||||
title: 'Deleted URL',
|
||||
message: '',
|
||||
icon: <Cross1Icon />,
|
||||
icon: <CrossIcon />,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
@@ -44,7 +43,7 @@ export default function Urls() {
|
||||
|
||||
const copyURL = u => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
|
||||
notif.showNotification({
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
@@ -58,12 +57,18 @@ export default function Urls() {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const onSubmit = async values => {
|
||||
const cleanURL = values.url.trim();
|
||||
const cleanVanity = values.vanity.trim();
|
||||
|
||||
if (cleanURL === '') return form.setFieldError('url', 'URL can\'t be nothing');
|
||||
|
||||
try {
|
||||
new URL(cleanURL);
|
||||
} catch (e) {
|
||||
return form.setFieldError('url', 'Invalid URL');
|
||||
}
|
||||
|
||||
const data = {
|
||||
url: cleanURL,
|
||||
vanity: cleanVanity === '' ? null : cleanVanity,
|
||||
@@ -81,18 +86,18 @@ export default function Urls() {
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
notif.showNotification({
|
||||
showNotification({
|
||||
title: 'Failed to create URL',
|
||||
message: json.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
showNotification({
|
||||
title: 'URL shortened',
|
||||
message: json.url,
|
||||
color: 'green',
|
||||
icon: <Link1Icon />,
|
||||
icon: <LinkIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,9 +126,9 @@ export default function Urls() {
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Group>
|
||||
<Title sx={{ marginBottom: 12 }}>URLs</Title>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setCreateOpen(true)}><PlusIcon/></ActionIcon>
|
||||
<Group mb='md'>
|
||||
<Title>URLs</Title>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setCreateOpen(true)}><PlusIcon /></ActionIcon>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid
|
||||
@@ -140,20 +145,18 @@ export default function Urls() {
|
||||
<Title>{url.vanity ?? url.id}</Title>
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
<ActionIcon href={url.url} component='a' target='_blank'><Link1Icon/></ActionIcon>
|
||||
<ActionIcon href={url.url} component='a' target='_blank'><LinkIcon /></ActionIcon>
|
||||
<ActionIcon aria-label='copy' onClick={() => copyURL(url)}>
|
||||
<CopyIcon />
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='delete' onClick={() => deleteURL(url)}>
|
||||
<TrashIcon />
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)) : [1,2,3,4,5,6,7].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={60} sx={{ borderRadius: 1 }}/>
|
||||
</div>
|
||||
)) : [1, 2, 3, 4].map(x => (
|
||||
<Skeleton key={x} width='100%' height={80} radius='sm' />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useForm } from '@mantine/hooks';
|
||||
import { Avatar, Modal, Title, TextInput, Group, Button, Card, ActionIcon, SimpleGrid, Switch, Skeleton, Checkbox } from '@mantine/core';
|
||||
import { Cross1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { ActionIcon, Avatar, Button, Card, Group, Modal, SimpleGrid, Skeleton, Stack, Switch, Text, TextInput, Title } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { CrossIcon, DeleteIcon, PlusIcon } from 'components/icons';
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
|
||||
function CreateUserModal({ open, setOpen, updateUsers }) {
|
||||
@@ -17,9 +18,8 @@ function CreateUserModal({ open, setOpen, updateUsers }) {
|
||||
administrator: false,
|
||||
},
|
||||
});
|
||||
const notif = useNotifications();
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const onSubmit = async values => {
|
||||
const cleanUsername = values.username.trim();
|
||||
const cleanPassword = values.password.trim();
|
||||
if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
|
||||
@@ -34,14 +34,14 @@ function CreateUserModal({ open, setOpen, updateUsers }) {
|
||||
setOpen(false);
|
||||
const res = await useFetch('/api/auth/create', 'POST', data);
|
||||
if (res.error) {
|
||||
notif.showNotification({
|
||||
showNotification({
|
||||
title: 'Failed to create user',
|
||||
message: res.error,
|
||||
icon: <TrashIcon />,
|
||||
icon: <DeleteIcon />,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
showNotification({
|
||||
title: 'Created user: ' + cleanUsername,
|
||||
message: '',
|
||||
icon: <PlusIcon />,
|
||||
@@ -58,7 +58,7 @@ function CreateUserModal({ open, setOpen, updateUsers }) {
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Title>Create User</Title>}
|
||||
>
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<form onSubmit={form.onSubmit(v => onSubmit(v))}>
|
||||
<TextInput id='username' label='Username' {...form.getInputProps('username')} />
|
||||
<TextInput id='password' label='Password' type='password' {...form.getInputProps('password')} />
|
||||
<Switch mt={12} id='administrator' label='Administrator' {...form.getInputProps('administrator')} />
|
||||
@@ -75,7 +75,6 @@ function CreateUserModal({ open, setOpen, updateUsers }) {
|
||||
export default function Users() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
const router = useRouter();
|
||||
const notif = useNotifications();
|
||||
const modals = useModals();
|
||||
|
||||
const [users, setUsers] = useState([]);
|
||||
@@ -87,18 +86,18 @@ export default function Users() {
|
||||
delete_images,
|
||||
});
|
||||
if (res.error) {
|
||||
notif.showNotification({
|
||||
showNotification({
|
||||
title: 'Failed to delete user',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
showNotification({
|
||||
title: 'User deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <TrashIcon />,
|
||||
icon: <DeleteIcon />,
|
||||
});
|
||||
updateUsers();
|
||||
}
|
||||
@@ -108,8 +107,6 @@ export default function Users() {
|
||||
const openDeleteModal = user => modals.openConfirmModal({
|
||||
title: `Delete ${user.username}?`,
|
||||
closeOnConfirm: false,
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
labels: { confirm: 'Yes', cancel: 'No' },
|
||||
onConfirm: () => {
|
||||
modals.openConfirmModal({
|
||||
@@ -145,8 +142,8 @@ export default function Users() {
|
||||
return (
|
||||
<>
|
||||
<CreateUserModal open={open} setOpen={setOpen} updateUsers={updateUsers} />
|
||||
<Group>
|
||||
<Title sx={{ marginBottom: 12 }}>Users</Title>
|
||||
<Group mb='md'>
|
||||
<Title>Users</Title>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon /></ActionIcon>
|
||||
</Group>
|
||||
<SimpleGrid
|
||||
@@ -160,20 +157,22 @@ export default function Users() {
|
||||
<Card key={user.id} sx={{ maxWidth: '100%' }}>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Avatar color={user.administrator ? 'primary' : 'dark'}>{user.username[0]}</Avatar>
|
||||
<Title>{user.username}</Title>
|
||||
<Avatar size='lg' color={user.administrator ? 'primary' : 'dark'} src={user.avatar ?? null}>{user.username[0]}</Avatar>
|
||||
<Stack spacing={0}>
|
||||
<Title>{user.username}</Title>
|
||||
<MutedText size='sm'>ID: {user.id}</MutedText>
|
||||
<MutedText size='sm'>Administrator: {user.administrator ? 'yes' : 'no'}</MutedText>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(user)}>
|
||||
<TrashIcon />
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)) : [1, 2, 3, 4].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
|
||||
</div>
|
||||
)) : [1, 2, 3].map(x => (
|
||||
<Skeleton key={x} width='100%' height={100} radius='sm' />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Image, User } from '@prisma/client';
|
||||
import type { Image, User } from '@prisma/client';
|
||||
|
||||
export function parse(str: string, image: Image, user: User) {
|
||||
if (!str) return null;
|
||||
@@ -13,4 +13,40 @@ export function parse(str: string, image: Image, user: User) {
|
||||
.replace(/{image.created_at.full_string}/gi, image.created_at.toLocaleString())
|
||||
.replace(/{image.created_at.time_string}/gi, image.created_at.toLocaleTimeString())
|
||||
.replace(/{image.created_at.date_string}/gi, image.created_at.toLocaleDateString());
|
||||
}
|
||||
}
|
||||
|
||||
export function bytesToRead(bytes: number) {
|
||||
if (isNaN(bytes)) return '0.0 B';
|
||||
if (bytes === Infinity) return '0.0 B';
|
||||
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let num = 0;
|
||||
|
||||
while (bytes > 1024) {
|
||||
bytes /= 1024;
|
||||
++num;
|
||||
}
|
||||
|
||||
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||
}
|
||||
|
||||
export const units = {
|
||||
year: 365 * 24 * 60 * 60 * 1000,
|
||||
month: 30 * 24 * 60 * 60 * 1000,
|
||||
day: 24 * 60 * 60 * 1000,
|
||||
hour: 60 * 60 * 1000,
|
||||
minute: 60 * 1000,
|
||||
second: 1000,
|
||||
};
|
||||
|
||||
export function relativeTime(to: Date, from: Date = new Date()) {
|
||||
const time = new Date(to.getTime() - from.getTime());
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat('en', { style: 'long' });
|
||||
|
||||
for (const unit in units) {
|
||||
if (time > units[unit]) {
|
||||
return rtf.format(Math.floor(Math.round(time.getTime() / units[unit])), unit as Intl.RelativeTimeFormatUnit || 'second');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
export default global.config as Config;
|
||||
@@ -1,6 +1,6 @@
|
||||
export interface ConfigCore {
|
||||
// Whether to return http or https links
|
||||
secure: boolean;
|
||||
https: boolean;
|
||||
|
||||
// Used for signing of cookies and other stuff
|
||||
secret: string;
|
||||
@@ -12,7 +12,7 @@ export interface ConfigCore {
|
||||
port: number;
|
||||
|
||||
// The PostgreSQL database url
|
||||
database_url: string
|
||||
database_url: string;
|
||||
|
||||
// Whether or not to log stuff
|
||||
logger: boolean;
|
||||
@@ -23,13 +23,15 @@ export interface ConfigCore {
|
||||
|
||||
export interface ConfigDatasource {
|
||||
// The type of datasource
|
||||
type: 'local' | 's3';
|
||||
type: 'local' | 's3' | 'swift';
|
||||
|
||||
// The local datasource, the default
|
||||
local: ConfigLocalDatasource;
|
||||
|
||||
|
||||
// The s3 datasource
|
||||
s3?: ConfigS3Datasource;
|
||||
// The Swift datasource
|
||||
swift?: ConfigSwiftDatasource;
|
||||
}
|
||||
|
||||
export interface ConfigLocalDatasource {
|
||||
@@ -53,6 +55,20 @@ export interface ConfigS3Datasource {
|
||||
// If true Zipline will attempt to connect to the bucket via the url "https://s3.amazonaws.com/{bucket}/stuff"
|
||||
// If false Zipline will attempt to connect to the bucket via the url "http://{bucket}.s3.amazonaws.com/stuff"
|
||||
force_s3_path: boolean;
|
||||
|
||||
// Region
|
||||
// aws region, default will be us-east-1 (if using a non-aws S3 service this might work for you)
|
||||
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 ConfigUploader {
|
||||
@@ -89,10 +105,18 @@ export interface ConfigRatelimit {
|
||||
admin: number;
|
||||
}
|
||||
|
||||
export interface ConfigWebsite {
|
||||
// Change the title from Zipline to something else
|
||||
title: string;
|
||||
// If zipline should show files per user in the stats page
|
||||
show_files_per_user: boolean;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
core: ConfigCore;
|
||||
uploader: ConfigUploader;
|
||||
urls: ConfigUrls;
|
||||
ratelimit: ConfigRatelimit;
|
||||
datasource: ConfigDatasource;
|
||||
}
|
||||
website: ConfigWebsite;
|
||||
}
|
||||
@@ -1,127 +1,116 @@
|
||||
import { parse } from 'dotenv';
|
||||
import { expand } from 'dotenv-expand';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import parse from '@iarna/toml/parse-string';
|
||||
import Logger from '../logger';
|
||||
import { Config } from './Config';
|
||||
|
||||
const e = (val, type, fn) => ({ val, type, fn });
|
||||
function isObject(value: any): value is Record<string, any> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
const envValues = [
|
||||
e('SECURE', 'boolean', (c, v) => c.core.secure = v),
|
||||
e('SECRET', 'string', (c, v) => c.core.secret = v),
|
||||
e('HOST', 'string', (c, v) => c.core.host = v),
|
||||
e('PORT', 'number', (c, v) => c.core.port = v),
|
||||
e('DATABASE_URL', 'string', (c, v) => c.core.database_url = v),
|
||||
e('LOGGER', 'boolean', (c, v) => c.core.logger = v ?? true),
|
||||
e('STATS_INTERVAL', 'number', (c, v) => c.core.stats_interval = v),
|
||||
function set(object: Record<string, any>, property: string, value: any) {
|
||||
const parts = property.split('.');
|
||||
|
||||
e('DATASOURCE_TYPE', 'string', (c, v) => c.datasource.type = v),
|
||||
e('DATASOURCE_LOCAL_DIRECTORY', 'string', (c, v) => c.datasource.local.directory = v),
|
||||
e('DATASOURCE_S3_ACCESS_KEY_ID', 'string', (c, v) => c.datasource.s3.access_key_id = v),
|
||||
e('DATASOURCE_S3_SECRET_ACCESS_KEY', 'string', (c, v) => c.datasource.s3.secret_access_key = v),
|
||||
e('DATASOURCE_S3_ENDPOINT', 'string', (c, v) => c.datasource.s3.endpoint = v ?? null),
|
||||
e('DATASOURCE_S3_FORCE_S3_PATH', 'boolean', (c, v) => c.datasource.s3.force_s3_path = v ?? false),
|
||||
e('DATASOURCE_S3_BUCKET', 'string', (c, v) => c.datasource.s3.bucket = v),
|
||||
for (let i = 0; i < parts.length; ++i) {
|
||||
const key = parts[i];
|
||||
|
||||
e('UPLOADER_ROUTE', 'string', (c, v) => c.uploader.route = v),
|
||||
e('UPLOADER_LENGTH', 'number', (c, v) => c.uploader.length = v),
|
||||
e('UPLOADER_ADMIN_LIMIT', 'number', (c, v) => c.uploader.admin_limit = v),
|
||||
e('UPLOADER_USER_LIMIT', 'number', (c, v) => c.uploader.user_limit = v),
|
||||
e('UPLOADER_DISABLED_EXTS', 'array', (c, v) => v ? c.uploader.disabled_extensions = v : c.uploader.disabled_extensions = []),
|
||||
if (i === parts.length - 1) {
|
||||
object[key] = value;
|
||||
} else if (!isObject(object[key])) {
|
||||
object[key] = typeof parts[i + 1] === 'number' ? [] : {};
|
||||
}
|
||||
|
||||
e('URLS_ROUTE', 'string', (c, v) => c.urls.route = v),
|
||||
e('URLS_LENGTH', 'number', (c, v) => c.urls.length = v),
|
||||
|
||||
e('RATELIMIT_USER', 'number', (c, v) => c.ratelimit.user = v ?? 0),
|
||||
e('RATELIMIT_ADMIN', 'number', (c, v) => c.ratelimit.user = v ?? 0),
|
||||
];
|
||||
|
||||
export default function readConfig(): Config {
|
||||
if (!existsSync(join(process.cwd(), 'config.toml'))) {
|
||||
if (!process.env.ZIPLINE_DOCKER_BUILD) Logger.get('config').info('reading environment');
|
||||
return tryReadEnv();
|
||||
} else {
|
||||
if (process.env.ZIPLINE_DOCKER_BUILD) return;
|
||||
|
||||
Logger.get('config').info('reading config file');
|
||||
const str = readFileSync(join(process.cwd(), 'config.toml'), 'utf8');
|
||||
const parsed = parse(str);
|
||||
|
||||
return parsed;
|
||||
object = object[key];
|
||||
}
|
||||
};
|
||||
|
||||
function tryReadEnv(): Config {
|
||||
const config = {
|
||||
core: {
|
||||
secure: undefined,
|
||||
secret: undefined,
|
||||
host: undefined,
|
||||
port: undefined,
|
||||
database_url: undefined,
|
||||
logger: undefined,
|
||||
stats_interval: undefined,
|
||||
},
|
||||
datasource: {
|
||||
type: undefined,
|
||||
local: {
|
||||
directory: undefined,
|
||||
},
|
||||
s3: {
|
||||
access_key_id: undefined,
|
||||
secret_access_key: undefined,
|
||||
endpoint: undefined,
|
||||
bucket: undefined,
|
||||
force_s3_path: undefined,
|
||||
},
|
||||
},
|
||||
uploader: {
|
||||
route: undefined,
|
||||
length: undefined,
|
||||
admin_limit: undefined,
|
||||
user_limit: undefined,
|
||||
disabled_extensions: undefined,
|
||||
},
|
||||
urls: {
|
||||
route: undefined,
|
||||
length: undefined,
|
||||
},
|
||||
ratelimit: {
|
||||
user: undefined,
|
||||
admin: undefined,
|
||||
},
|
||||
return object;
|
||||
}
|
||||
|
||||
function map(env: string, type: 'string' | 'number' | 'boolean' | 'array', path: string) {
|
||||
return {
|
||||
env,
|
||||
type,
|
||||
path,
|
||||
};
|
||||
}
|
||||
|
||||
for (let i = 0, L = envValues.length; i !== L; ++i) {
|
||||
const envValue = envValues[i];
|
||||
let value: any = process.env[envValue.val];
|
||||
export default function readConfig() {
|
||||
if (existsSync('.env.local')) {
|
||||
const contents = readFileSync('.env.local');
|
||||
|
||||
if (!value) {
|
||||
envValues[i].fn(config, undefined);
|
||||
} else {
|
||||
envValues[i].fn(config, value);
|
||||
if (envValue.type === 'number') value = parseToNumber(value);
|
||||
else if (envValue.type === 'boolean') value = parseToBoolean(value);
|
||||
else if (envValue.type === 'array') value = parseToArray(value);
|
||||
envValues[i].fn(config, value);
|
||||
expand({
|
||||
parsed: parse(contents),
|
||||
});
|
||||
}
|
||||
|
||||
const maps = [
|
||||
map('CORE_HTTPS', 'boolean', 'core.secure'),
|
||||
map('CORE_SECRET', 'string', 'core.secret'),
|
||||
map('CORE_HOST', 'string', 'core.host'),
|
||||
map('CORE_PORT', 'number', 'core.port'),
|
||||
map('CORE_DATABASE_URL', 'string', 'core.database_url'),
|
||||
map('CORE_LOGGER', 'boolean', 'core.logger'),
|
||||
map('CORE_STATS_INTERVAL', 'number', 'core.stats_interval'),
|
||||
|
||||
map('DATASOURCE_TYPE', 'string', 'datasource.type'),
|
||||
|
||||
map('DATASOURCE_LOCAL_DIRECTORY', 'string', 'datasource.local.directory'),
|
||||
|
||||
map('DATASOURCE_S3_ACCESS_KEY_ID', 'string', 'datasource.s3.access_key_id'),
|
||||
map('DATASOURCE_S3_SECRET_ACCESS_KEY', 'string', 'datasource.s3.secret_access_key'),
|
||||
map('DATASOURCE_S3_ENDPOINT', 'string', 'datasource.s3.endpoint'),
|
||||
map('DATASOURCE_S3_BUCKET', 'string', 'datasource.s3.bucket'),
|
||||
map('DATASOURCE_S3_FORCE_S3_PATH', 'boolean', 'datasource.s3.force_s3_path'),
|
||||
map('DATASOURCE_S3_REGION', 'string', 'datasource.s3.region'),
|
||||
|
||||
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('UPLOADER_ROUTE', 'string', 'uploader.route'),
|
||||
map('UPLOADER_LENGTH', 'number', 'uploader.length'),
|
||||
map('UPLOADER_ADMIN_LIMIT', 'number', 'uploader.admin_limit'),
|
||||
map('UPLOADER_USER_LIMIT', 'number', 'uploader.user_limit'),
|
||||
map('UPLOADER_DISABLED_EXTENSIONS', 'array', 'uploader.disabled_extensions'),
|
||||
|
||||
map('URLS_ROUTE', 'string', 'urls.route'),
|
||||
map('URLS_LENGTH', 'number', 'urls.length'),
|
||||
|
||||
map('RATELIMIT_USER', 'number', 'ratelimit.user'),
|
||||
map('RATELIMIT_ADMIN', 'number', 'ratelimit.admin'),
|
||||
|
||||
map('WEBSITE_TITLE', 'string', 'website.title'),
|
||||
map('WEBSITE_SHOW_FILES_PER_USER', 'boolean', 'website.show_files_per_user'),
|
||||
];
|
||||
|
||||
const config = {};
|
||||
|
||||
for (let i = 0; i !== maps.length; ++i) {
|
||||
const map = maps[i];
|
||||
|
||||
const value = process.env[map.env];
|
||||
|
||||
if (value) {
|
||||
let parsed: any;
|
||||
switch (map.type) {
|
||||
case 'array':
|
||||
parsed = value.split(',');
|
||||
break;
|
||||
case 'number':
|
||||
parsed = Number(value);
|
||||
break;
|
||||
case 'boolean':
|
||||
parsed = value === 'true';
|
||||
break;
|
||||
default:
|
||||
parsed = value;
|
||||
};
|
||||
|
||||
set(config, map.path, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function parseToNumber(value) {
|
||||
// infer that it is a string since env values are only strings
|
||||
const number = Number(value);
|
||||
if (isNaN(number)) return undefined;
|
||||
return number;
|
||||
}
|
||||
|
||||
function parseToBoolean(value) {
|
||||
// infer that it is a string since env values are only strings
|
||||
if (!value || value === 'false') return false;
|
||||
else return true;
|
||||
}
|
||||
|
||||
function parseToArray(value) {
|
||||
return value.split(',');
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { object, bool, string, number, boolean, array } from 'yup';
|
||||
|
||||
const validator = object({
|
||||
core: object({
|
||||
secure: bool().default(false),
|
||||
https: bool().default(false),
|
||||
secret: string().min(8).required(),
|
||||
host: string().default('0.0.0.0'),
|
||||
port: number().default(3000),
|
||||
@@ -12,17 +12,27 @@ const validator = object({
|
||||
stats_interval: number().default(1800),
|
||||
}).required(),
|
||||
datasource: object({
|
||||
type: string().default('local'),
|
||||
type: string().oneOf(['local', 's3', 'swift']).default('local'),
|
||||
local: object({
|
||||
directory: string().default('./uploads'),
|
||||
}),
|
||||
s3: object({
|
||||
access_key_id: string(),
|
||||
secret_access_key: string(),
|
||||
endpoint: string().notRequired().nullable(),
|
||||
endpoint: string(),
|
||||
bucket: string(),
|
||||
force_s3_path: boolean().default(false),
|
||||
}).notRequired(),
|
||||
region: string().default('us-east-1'),
|
||||
}).nullable().notRequired(),
|
||||
swift: object({
|
||||
username: string(),
|
||||
password: string(),
|
||||
auth_endpoint: string(),
|
||||
container: string(),
|
||||
project_id: string(),
|
||||
domain_id: string().default('default'),
|
||||
region_id: string().nullable(),
|
||||
}).nullable().notRequired(),
|
||||
}).required(),
|
||||
uploader: object({
|
||||
route: string().default('/u'),
|
||||
@@ -40,23 +50,49 @@ const validator = object({
|
||||
user: number().default(0),
|
||||
admin: number().default(0),
|
||||
}),
|
||||
website: object({
|
||||
title: string().default('Zipline'),
|
||||
show_files_per_user: boolean().default(true),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
export default function validate(config): Config {
|
||||
try {
|
||||
const validated = validator.validateSync(config, { abortEarly: false });
|
||||
if (validated.datasource.type === 's3') {
|
||||
const errors = [];
|
||||
if (!validated.datasource.s3.access_key_id) errors.push('datasource.s3.access_key_id is a required field');
|
||||
if (!validated.datasource.s3.secret_access_key) errors.push('datasource.s3.secret_access_key is a required field');
|
||||
if (!validated.datasource.s3.bucket) errors.push('datasource.s3.bucket is a required field');
|
||||
if (errors.length) throw { errors };
|
||||
switch (validated.datasource.type) {
|
||||
case 's3': {
|
||||
const errors = [];
|
||||
if (!validated.datasource.s3.access_key_id)
|
||||
errors.push('datasource.s3.access_key_id is a required field');
|
||||
if (!validated.datasource.s3.secret_access_key)
|
||||
errors.push('datasource.s3.secret_access_key is a required field');
|
||||
if (!validated.datasource.s3.bucket)
|
||||
errors.push('datasource.s3.bucket is a required field');
|
||||
if (!validated.datasource.s3.endpoint)
|
||||
errors.push('datasource.s3.endpoint is a required field');
|
||||
if (errors.length) throw { errors };
|
||||
break;
|
||||
}
|
||||
case 'swift': {
|
||||
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 (errors.length) throw { errors };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return validated as unknown as Config;
|
||||
} catch (e) {
|
||||
if (process.env.ZIPLINE_DOCKER_BUILD) return null;
|
||||
throw `${e.errors.length} errors occured\n${e.errors.map(x => '\t' + x).join('\n')}`;
|
||||
throw `${e.errors.length} errors occured\n${e.errors.map((x) => '\t' + x).join('\n')}`;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
import config from './config';
|
||||
import { S3, Local } from './datasources';
|
||||
import { Swift, Local, S3 } from './datasources';
|
||||
import Logger from './logger';
|
||||
|
||||
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`);
|
||||
break;
|
||||
case 'local':
|
||||
global.datasource = new Local(config.datasource.local.directory);
|
||||
Logger.get('datasource').info(`Using local(${config.datasource.local.directory}) datasource`);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid datasource type');
|
||||
case 's3':
|
||||
global.datasource = new S3(config.datasource.s3);
|
||||
Logger.get('datasource').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`);
|
||||
break;
|
||||
case 'swift':
|
||||
global.datasource = new Swift(config.datasource.swift);
|
||||
Logger.get('datasource').info(`using Swift(${config.datasource.swift.container}) datasource`);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid datasource type');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 get(file: string): Readable;
|
||||
public abstract size(): Promise<number>;
|
||||
public abstract size(file: string): Promise<number>;
|
||||
public abstract get(file: string): Readable | Promise<Readable>;
|
||||
public abstract fullSize(): Promise<number>;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createReadStream, existsSync, ReadStream } from 'fs';
|
||||
import { readdir, rm, stat, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { Datasource } from './';
|
||||
import { Datasource } from '.';
|
||||
|
||||
export class Local extends Datasource {
|
||||
public name: string = 'local';
|
||||
@@ -29,7 +29,13 @@ export class Local extends Datasource {
|
||||
}
|
||||
}
|
||||
|
||||
public async size(): Promise<number> {
|
||||
public async size(file: string): Promise<number> {
|
||||
const stats = await stat(join(process.cwd(), this.path, file));
|
||||
|
||||
return stats.size;
|
||||
}
|
||||
|
||||
public async fullSize(): Promise<number> {
|
||||
const files = await readdir(this.path);
|
||||
|
||||
let size = 0;
|
||||
|
||||
@@ -1,74 +1,60 @@
|
||||
import { Datasource } from './';
|
||||
import AWS from 'aws-sdk';
|
||||
import { Datasource } from '.';
|
||||
import { Readable } from 'stream';
|
||||
import { ConfigS3Datasource } from 'lib/config/Config';
|
||||
import { Client } from 'minio';
|
||||
|
||||
export class S3 extends Datasource {
|
||||
public name: string = 'S3';
|
||||
public s3: AWS.S3;
|
||||
public s3: Client;
|
||||
|
||||
public constructor(
|
||||
public config: ConfigS3Datasource,
|
||||
) {
|
||||
super();
|
||||
this.s3 = new AWS.S3({
|
||||
accessKeyId: config.access_key_id,
|
||||
endpoint: config.endpoint || null,
|
||||
s3ForcePathStyle: config.force_s3_path,
|
||||
secretAccessKey: config.secret_access_key,
|
||||
this.s3 = new Client({
|
||||
endPoint: config.endpoint,
|
||||
accessKey: config.access_key_id,
|
||||
secretKey: config.secret_access_key,
|
||||
pathStyle: config.force_s3_path,
|
||||
region: config.region,
|
||||
});
|
||||
}
|
||||
|
||||
public async save(file: string, data: Buffer): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.s3.upload({
|
||||
Bucket: this.config.bucket,
|
||||
Key: file,
|
||||
Body: data,
|
||||
}, err => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
await this.s3.putObject(this.config.bucket, file, data);
|
||||
}
|
||||
|
||||
public async delete(file: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.s3.deleteObject({
|
||||
Bucket: this.config.bucket,
|
||||
Key: file,
|
||||
}, err => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
await this.s3.removeObject(this.config.bucket, file);
|
||||
}
|
||||
|
||||
public get(file: string): Promise<Readable> {
|
||||
return new Promise((res, rej) => {
|
||||
this.s3.getObject(this.config.bucket, file, (err, stream) => {
|
||||
if (err) res(null);
|
||||
else res(stream);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public get(file: string): Readable {
|
||||
// Unfortunately, aws-sdk is bad and the stream still loads everything into memory.
|
||||
return this.s3.getObject({
|
||||
Bucket: this.config.bucket,
|
||||
Key: file,
|
||||
}).createReadStream();
|
||||
public size(file: string): Promise<number> {
|
||||
return new Promise((res, rej) => {
|
||||
this.s3.statObject(this.config.bucket, file, (err, stat) => {
|
||||
if (err) rej(err);
|
||||
else res(stat.size);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async size(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.s3.listObjects({
|
||||
Bucket: this.config.bucket,
|
||||
}, (err, data) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
const size = data.Contents.reduce((acc, cur) => acc + cur.Size, 0);
|
||||
resolve(size);
|
||||
}
|
||||
public async fullSize(): Promise<number> {
|
||||
return new Promise((res, rej) => {
|
||||
const objects = this.s3.listObjectsV2(this.config.bucket, '', true);
|
||||
let size = 0;
|
||||
|
||||
objects.on('data', item => size += item.size);
|
||||
objects.on('end', err => {
|
||||
if (err) rej(err);
|
||||
else res(size);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
232
src/lib/datasources/Swift.ts
Normal file
232
src/lib/datasources/Swift.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { Datasource } from '.';
|
||||
import { Readable } from 'stream';
|
||||
import { ConfigSwiftDatasource } from 'lib/config/Config';
|
||||
|
||||
interface SwiftContainerOptions {
|
||||
auth_endpoint_url: string;
|
||||
credentials: {
|
||||
username: string;
|
||||
password: string;
|
||||
project_id: string;
|
||||
domain_id: string;
|
||||
container: string;
|
||||
interface?: string;
|
||||
region_id: string;
|
||||
};
|
||||
refreshMargin?: number;
|
||||
}
|
||||
|
||||
interface SwiftAuth {
|
||||
token: string;
|
||||
expires: Date;
|
||||
swiftURL: string;
|
||||
}
|
||||
|
||||
interface SwiftObject {
|
||||
bytes: number;
|
||||
content_type: string;
|
||||
hash: string;
|
||||
name: string;
|
||||
last_modified: string;
|
||||
}
|
||||
|
||||
class SwiftContainer {
|
||||
auth: SwiftAuth | null;
|
||||
|
||||
constructor(private options: SwiftContainerOptions) {
|
||||
this.auth = null;
|
||||
}
|
||||
|
||||
private findEndpointURL(catalog: any[], service: string): string | null {
|
||||
const catalogEntry = catalog.find((x) => x.name === service);
|
||||
if (!catalogEntry) return null;
|
||||
|
||||
const endpoint = catalogEntry.endpoints.find(
|
||||
(x: any) =>
|
||||
x.interface === (this.options.credentials.interface || 'public') &&
|
||||
(this.options.credentials.region_id
|
||||
? x.region_id == this.options.credentials.region_id
|
||||
: true)
|
||||
);
|
||||
|
||||
return endpoint ? endpoint.url : null;
|
||||
}
|
||||
|
||||
private async getCredentials(): Promise<SwiftAuth> {
|
||||
const payload = {
|
||||
auth: {
|
||||
identity: {
|
||||
methods: ['password'],
|
||||
password: {
|
||||
user: {
|
||||
name: this.options.credentials.username,
|
||||
password: this.options.credentials.password,
|
||||
domain: {
|
||||
id: this.options.credentials.domain_id || 'default',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scope: {
|
||||
project: {
|
||||
id: this.options.credentials.project_id,
|
||||
domain: {
|
||||
id: this.options.credentials.domain_id || 'default',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { json, headers, error } = await fetch(`${this.options.auth_endpoint_url}/auth/tokens`, {
|
||||
body: JSON.stringify(payload),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}).then(async (e) => {
|
||||
try {
|
||||
const json = await e.json();
|
||||
return { json, headers: e.headers, error: null };
|
||||
} catch (e) {
|
||||
return { json: null, headers: null, error: e };
|
||||
}
|
||||
});
|
||||
|
||||
if (error || !json || !headers || json.error) throw new Error('Could not retrieve credentials from OpenStack, check your config file');
|
||||
|
||||
const catalog = json.token.catalog;
|
||||
// many Swift clouds use ceph radosgw to provide swift
|
||||
const swiftURL = this.findEndpointURL(catalog, 'swift') || this.findEndpointURL(catalog, 'radosgw-swift');
|
||||
if (!swiftURL) throw new Error('Couldn\'t find any "swift" or "radosgw-swift" service in the catalog');
|
||||
|
||||
return {
|
||||
token: headers.get('x-subject-token'),
|
||||
expires: new Date(json.token.expires_at),
|
||||
swiftURL,
|
||||
};
|
||||
}
|
||||
|
||||
private async authenticate() {
|
||||
if (!this.auth) this.auth = await this.getCredentials();
|
||||
const authExpiry = new Date(Date.now() + this.options.refreshMargin || 10_000);
|
||||
|
||||
if (authExpiry > this.auth.expires) this.auth = await this.getCredentials();
|
||||
const validAuth = this.auth;
|
||||
|
||||
return { swiftURL: validAuth.swiftURL, token: validAuth.token };
|
||||
}
|
||||
|
||||
private generateHeaders(token: string, extra?: any) {
|
||||
return { accept: 'application/json', 'x-auth-token': token, ...extra };
|
||||
}
|
||||
|
||||
public async listObjects(query?: string): Promise<SwiftObject[]> {
|
||||
const auth = await this.authenticate();
|
||||
return await fetch(`${auth.swiftURL}/${this.options.credentials.container}${query ? `${query.startsWith('?') ? '' : '?'}${query}` : ''}`, {
|
||||
method: 'GET',
|
||||
headers: this.generateHeaders(auth.token),
|
||||
}).then((e) => e.json());
|
||||
}
|
||||
|
||||
public async uploadObject(name: string, data: Buffer): Promise<any> {
|
||||
const auth = await this.authenticate();
|
||||
|
||||
return fetch(`${auth.swiftURL}/${this.options.credentials.container}/${name}`, {
|
||||
method: 'PUT',
|
||||
headers: this.generateHeaders(auth.token),
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteObject(name: string): Promise<any> {
|
||||
const auth = await this.authenticate();
|
||||
|
||||
return fetch(`${auth.swiftURL}/${this.options.credentials.container}/${name}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.generateHeaders(auth.token),
|
||||
});
|
||||
}
|
||||
|
||||
public async getObject(name: string): Promise<Readable> {
|
||||
const auth = await this.authenticate();
|
||||
|
||||
const arrayBuffer = await fetch(`${auth.swiftURL}/${this.options.credentials.container}/${name}`, {
|
||||
method: 'GET',
|
||||
headers: this.generateHeaders(auth.token, { Accept: '*/*' }),
|
||||
}).then((e) => e.arrayBuffer());
|
||||
|
||||
return Readable.from(Buffer.from(arrayBuffer));
|
||||
}
|
||||
|
||||
public async headObject(name: string): Promise<any> {
|
||||
const auth = await this.authenticate();
|
||||
|
||||
return fetch(`${auth.swiftURL}/${this.options.credentials.container}/${name}`, {
|
||||
method: 'HEAD',
|
||||
headers: this.generateHeaders(auth.token),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class Swift extends Datasource {
|
||||
public name: string = 'Swift';
|
||||
container: SwiftContainer;
|
||||
|
||||
public constructor(public config: ConfigSwiftDatasource) {
|
||||
super();
|
||||
this.container = new SwiftContainer({
|
||||
auth_endpoint_url: config.auth_endpoint,
|
||||
credentials: {
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
project_id: config.project_id,
|
||||
domain_id: config.domain_id || 'default',
|
||||
container: config.container,
|
||||
region_id: config.region_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async save(file: string, data: Buffer): Promise<void> {
|
||||
try {
|
||||
return this.container.uploadObject(file, data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async delete(file: string): Promise<void> {
|
||||
try {
|
||||
return this.container.deleteObject(file);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public get(file: string): Promise<Readable> | Readable {
|
||||
try {
|
||||
return this.container.getObject(file);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async size(file: string): Promise<number> {
|
||||
try {
|
||||
const head = await this.container.headObject(file);
|
||||
|
||||
return head.headers.get('content-length') || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async fullSize(): Promise<number> {
|
||||
return this.container
|
||||
.listObjects()
|
||||
.then((objects) => objects.reduce((acc, object) => acc + object.bytes, 0))
|
||||
.catch(() => 0);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { Datasource } from './Datasource';
|
||||
export { Local } from './Local';
|
||||
export { S3 } from './S3';
|
||||
export { S3 } from './S3';
|
||||
export { Swift } from './Swift';
|
||||
45
src/lib/exts.ts
Normal file
45
src/lib/exts.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// https://github.com/toptal/haste-server/blob/master/static/application.js#L167-L174
|
||||
// Popular extension map
|
||||
const exts = {
|
||||
'md': 'Markdown',
|
||||
'css': 'CSS',
|
||||
'js': 'JavaScript',
|
||||
'json': 'JSON',
|
||||
'html': 'HTML',
|
||||
'ts': 'TypeScript',
|
||||
'java': 'Java',
|
||||
'py': 'Python',
|
||||
'rb': 'Ruby',
|
||||
'sh': 'Shell',
|
||||
'php': 'PHP',
|
||||
'pl': 'Perl',
|
||||
'sql': 'SQL',
|
||||
'xml': 'XML',
|
||||
'yml': 'YAML',
|
||||
'yaml': 'YAML',
|
||||
'c': 'C',
|
||||
'cpp': 'C++',
|
||||
'cs': 'C#',
|
||||
'go': 'Go',
|
||||
'h': 'C/C++ Header',
|
||||
'txt': 'Text',
|
||||
'dockerfile': 'Dockerfile',
|
||||
'toml': 'TOML',
|
||||
'ini': 'INI',
|
||||
'bat': 'Batch File',
|
||||
'tex': 'TeX',
|
||||
'r': 'R',
|
||||
'lua': 'Lua',
|
||||
'ps1': 'PowerShell',
|
||||
'rst': 'reStructuredText',
|
||||
'rs': 'Rust',
|
||||
'swift': 'Swift',
|
||||
'scss': 'SCSS',
|
||||
'less': 'LESS',
|
||||
'scala': 'Scala',
|
||||
'kotlin': 'Kotlin',
|
||||
'vb': 'Visual Basic',
|
||||
'vim': 'Vim Script',
|
||||
};
|
||||
|
||||
export default exts;
|
||||
@@ -10,7 +10,8 @@ export default class Logger {
|
||||
public name: string;
|
||||
|
||||
static get(clas: any) {
|
||||
if (typeof clas !== 'function') if (typeof clas !== 'string') throw new Error('not string/function');
|
||||
if (typeof clas !== 'function')
|
||||
if (typeof clas !== 'string') throw new Error('not string/function');
|
||||
|
||||
const name = clas.name ?? clas;
|
||||
|
||||
@@ -26,7 +27,13 @@ export default class Logger {
|
||||
}
|
||||
|
||||
error(...args: any[]) {
|
||||
console.log(this.formatMessage(LoggerLevel.ERROR, this.name, args.map(error => error.stack ?? error).join(' ')));
|
||||
console.log(
|
||||
this.formatMessage(
|
||||
LoggerLevel.ERROR,
|
||||
this.name,
|
||||
args.map((error) => error.stack ?? error).join(' ')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
formatMessage(level: LoggerLevel, name: string, message: string) {
|
||||
@@ -36,10 +43,10 @@ export default class Logger {
|
||||
|
||||
formatLevel(level: LoggerLevel) {
|
||||
switch (level) {
|
||||
case LoggerLevel.INFO:
|
||||
return cyan('INFO ');
|
||||
case LoggerLevel.ERROR:
|
||||
return red('ERROR');
|
||||
case LoggerLevel.INFO:
|
||||
return cyan('INFO ');
|
||||
case LoggerLevel.ERROR:
|
||||
return red('ERROR');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { CookieSerializeOptions } from 'cookie';
|
||||
|
||||
import { serialize } from 'cookie';
|
||||
import { sign64, unsign64 } from '../util';
|
||||
import { sign64, unsign64 } from 'lib/util';
|
||||
import config from 'lib/config';
|
||||
import prisma from 'lib/prisma';
|
||||
|
||||
@@ -26,6 +26,7 @@ export type NextApiReq = NextApiRequest & {
|
||||
id: number;
|
||||
password: string;
|
||||
domains: string[];
|
||||
avatar?: string;
|
||||
} | null | void>;
|
||||
getCookie: (name: string) => string | null;
|
||||
cleanCookie: (name: string) => void;
|
||||
@@ -36,7 +37,7 @@ export type NextApiRes = NextApiResponse & {
|
||||
error: (message: string) => void;
|
||||
forbid: (message: string, extra?: any) => void;
|
||||
bad: (message: string) => void;
|
||||
json: (json: any) => void;
|
||||
json: (json: Record<string, any>, status?: number) => void;
|
||||
ratelimited: (remaining: number) => void;
|
||||
setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void;
|
||||
}
|
||||
@@ -50,23 +51,20 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
||||
res.error = (message: string) => {
|
||||
res.json({
|
||||
error: message,
|
||||
});
|
||||
}, 500);
|
||||
};
|
||||
|
||||
res.forbid = (message: string, extra: any = {}) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.status(403);
|
||||
res.json({
|
||||
error: '403: ' + message,
|
||||
...extra,
|
||||
});
|
||||
}, 403);
|
||||
};
|
||||
|
||||
res.bad = (message: string) => {
|
||||
res.status(401);
|
||||
res.json({
|
||||
error: '403: ' + message,
|
||||
});
|
||||
error: '401: ' + message,
|
||||
}, 401);
|
||||
};
|
||||
|
||||
res.ratelimited = (remaining: number) => {
|
||||
@@ -77,7 +75,9 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
||||
});
|
||||
};
|
||||
|
||||
res.json = (json: any) => {
|
||||
res.json = (json: any, status: number = 200) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.status(status);
|
||||
res.end(JSON.stringify(json));
|
||||
};
|
||||
|
||||
@@ -115,6 +115,7 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
||||
token: true,
|
||||
username: true,
|
||||
domains: true,
|
||||
avatar: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
12
src/lib/mimes.ts
Normal file
12
src/lib/mimes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
export type Mimes = [string, string[]][]
|
||||
|
||||
export async function guess(extension: string): Promise<string> {
|
||||
const mimes: Mimes = JSON.parse(await readFile('./mimes.json', 'utf8'));
|
||||
|
||||
const mime = mimes.find(x => x[0] === extension);
|
||||
if (!mime) return 'application/octet-stream';
|
||||
|
||||
return mime[1][0];
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export interface User {
|
||||
embedSiteName: string;
|
||||
systemTheme: string;
|
||||
domains: string[];
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
const initialState: User = null;
|
||||
|
||||
@@ -2,9 +2,8 @@ import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
|
||||
import { hash, verify } from 'argon2';
|
||||
import { readdir, stat } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import prisma from './prisma';
|
||||
import prisma from 'lib/prisma';
|
||||
import { InvisibleImage, InvisibleUrl } from '@prisma/client';
|
||||
import config from './config';
|
||||
|
||||
export async function hashPassword(s: string): Promise<string> {
|
||||
return await hash(s);
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@mantine/core';
|
||||
import { Button, Stack, Title } from '@mantine/core';
|
||||
import Link from 'components/Link';
|
||||
import MutedText from 'components/MutedText';
|
||||
|
||||
export default function FourOhFour() {
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text size='xl'>404 - Not Found</Text>
|
||||
</Box>
|
||||
</>
|
||||
<Stack
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
spacing='sm'
|
||||
>
|
||||
<Title sx={{ fontSize: 220, fontWeight: 900, lineHeight: .8 }}>404</Title>
|
||||
<MutedText sx={{ fontSize: 40, fontWeight: 500 }}>This page does not exist!</MutedText>
|
||||
<Button component={Link} href='/dashboard'>Head to the Dashboard</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FourOhFour.title = 'Zipline - 404';
|
||||
25
src/pages/500.tsx
Normal file
25
src/pages/500.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Button, Stack, Title } from '@mantine/core';
|
||||
import Link from 'components/Link';
|
||||
import MutedText from 'components/MutedText';
|
||||
|
||||
export default function FiveHundred() {
|
||||
return (
|
||||
<Stack
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
spacing='sm'
|
||||
>
|
||||
<Title sx={{ fontSize: 220, fontWeight: 900, lineHeight: .8 }}>500</Title>
|
||||
<MutedText sx={{ fontSize: 40, fontWeight: 500 }}>Internal Server Error</MutedText>
|
||||
<Button component={Link} href='/dashboard'>Head to the Dashboard</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
FiveHundred.title = 'Zipline - 500';
|
||||
@@ -5,7 +5,7 @@ import { Box, Button, Modal, PasswordInput } from '@mantine/core';
|
||||
import config from 'lib/config';
|
||||
import prisma from 'lib/prisma';
|
||||
import { parse } from 'lib/clientUtils';
|
||||
import * as exts from '../../scripts/exts';
|
||||
import exts from 'lib/exts';
|
||||
|
||||
export default function EmbeddedImage({ image, user, pass }) {
|
||||
const dataURL = (route: string) => `${route}/${image.file}`;
|
||||
@@ -65,15 +65,26 @@ export default function EmbeddedImage({ image, user, pass }) {
|
||||
)}
|
||||
{image.mimetype.startsWith('image') && (
|
||||
<>
|
||||
<meta property='og:image' content={dataURL('/r')} />
|
||||
<meta property='og:image' content={`/r/${image.file}`} />
|
||||
<meta property='twitter:card' content='summary_large_image' />
|
||||
</>
|
||||
)}
|
||||
{image.mimetype.startsWith('video') && (
|
||||
<>
|
||||
<meta property='og:video' content={dataURL('/r')} />
|
||||
<meta property='og:video:url' content={dataURL('/r')} />
|
||||
<meta name='twitter:card' content='player' />
|
||||
<meta name='twitter:player:stream' content={`/r/${image.file}`} />
|
||||
<meta name='twitter:player:width' content='720' />
|
||||
<meta name='twitter:player:height' content='480' />
|
||||
<meta name='twitter:player:stream:content_type' content={image.mimetype} />
|
||||
<meta name='twitter:title' content={image.file} />
|
||||
|
||||
<meta property='og:url' content={`/r/${image.file}`} />
|
||||
<meta property='og:video' content={`/r/${image.file}`} />
|
||||
<meta property='og:video:url' content={`/r/${image.file}`} />
|
||||
<meta property='og:video:secure_url' content={`/r/${image.file}`} />
|
||||
<meta property='og:video:type' content={image.mimetype} />
|
||||
<meta property='og:video:width' content='720' />
|
||||
<meta property='og:video:height' content='480' />
|
||||
</>
|
||||
)}
|
||||
<title>{image.file}</title>
|
||||
@@ -195,7 +206,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
if (!image.mimetype.startsWith('image') && !image.mimetype.startsWith('video')) {
|
||||
const { default: datasource } = await import('lib/datasource');
|
||||
|
||||
const data = datasource.get(image.file);
|
||||
const data = await datasource.get(image.file);
|
||||
if (!data) return { notFound: true };
|
||||
|
||||
data.pipe(context.res);
|
||||
@@ -214,3 +225,4 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
return { notFound: true };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ export default function MyApp({ Component, pageProps }) {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Head>
|
||||
<title>{Component.title}</title>
|
||||
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width' />
|
||||
</Head>
|
||||
<ZiplineTheming Component={Component} pageProps={pageProps} />
|
||||
|
||||
30
src/pages/_error.tsx
Normal file
30
src/pages/_error.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Button, Stack, Title } from '@mantine/core';
|
||||
import Link from 'components/Link';
|
||||
import MutedText from 'components/MutedText';
|
||||
|
||||
export default function Error({ statusCode }) {
|
||||
return (
|
||||
<Stack
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
spacing='sm'
|
||||
>
|
||||
<Title sx={{ fontSize: 220, fontWeight: 900, lineHeight: .8 }}>{statusCode}</Title>
|
||||
<MutedText sx={{ fontSize: 40, fontWeight: 500 }}>Something went wrong...</MutedText>
|
||||
<Button component={Link} href='/dashboard'>Head to the Dashboard</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function getInitialProps({ res, err }) {
|
||||
const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
|
||||
return { pageProps: { statusCode } };
|
||||
}
|
||||
|
||||
Error.title = 'Zipline - Something went wrong...';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user