mirror of
https://github.com/diced/zipline.git
synced 2025-12-10 23:00:46 -08:00
Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7963bdd1e4 | ||
|
|
195c57edc3 | ||
|
|
4442c85dc1 | ||
|
|
5bcac2a2b0 | ||
|
|
5303b67d11 | ||
|
|
af59e9abb8 | ||
|
|
fb098c9147 | ||
|
|
739974bef4 | ||
|
|
d21e48a1a3 | ||
|
|
8fea0cbe77 | ||
|
|
1e2b8efb13 | ||
|
|
8495963094 | ||
|
|
06d1c0bc3b | ||
|
|
5965c2e237 | ||
|
|
fb34dfadb0 | ||
|
|
13b0ac737b | ||
|
|
300430b3ec | ||
|
|
cf6f154e6e | ||
|
|
2ddf8c0cdb | ||
|
|
2a402f77b5 | ||
|
|
7b2c31658a | ||
|
|
7a91a60af9 | ||
|
|
bfa6c70bf3 | ||
|
|
73eff05180 | ||
|
|
74f3b3f13d | ||
|
|
181833d768 | ||
|
|
be9523304a | ||
|
|
b26fef3ad4 | ||
|
|
9f86674bbe | ||
|
|
095e57a037 | ||
|
|
66a8e3bb79 | ||
|
|
473137abdf | ||
|
|
740f1605e7 | ||
|
|
0922ec020e | ||
|
|
dbe8291f55 | ||
|
|
9dcc16277e | ||
|
|
aa611fa6ba | ||
|
|
083040e300 | ||
|
|
99e92e4594 | ||
|
|
870f6e88b1 | ||
|
|
16d2014bfb | ||
|
|
4d9a22e82c | ||
|
|
42d77e445b | ||
|
|
6506846207 | ||
|
|
2b9af0e0de | ||
|
|
762d2927f7 | ||
|
|
d9561f3b12 | ||
|
|
dde24848d4 | ||
|
|
e786482902 | ||
|
|
4e64922b70 | ||
|
|
15042b16d1 | ||
|
|
5e4c4fc6c9 | ||
|
|
7194c53891 | ||
|
|
7eff77ccc4 | ||
|
|
1b78ffaa91 | ||
|
|
8e8bfd68d1 | ||
|
|
b029505cdd | ||
|
|
c5c862bee3 | ||
|
|
3c38d008f1 | ||
|
|
dc52b00a00 | ||
|
|
b5d2e7040e | ||
|
|
5818440721 | ||
|
|
f1c46da47d | ||
|
|
212c69d303 | ||
|
|
9e4152e298 | ||
|
|
307f023e47 | ||
|
|
3451bd8762 | ||
|
|
a9d0be8aae | ||
|
|
d83f720631 | ||
|
|
1f3d396296 | ||
|
|
48f771f344 | ||
|
|
555bc6aa26 | ||
|
|
8bd0eaac1e | ||
|
|
3280c77002 | ||
|
|
b39743a53a | ||
|
|
9a73da56e9 | ||
|
|
c9b0d2664f | ||
|
|
6063c9efac | ||
|
|
dd6f192d4a | ||
|
|
d956f4ed3d | ||
|
|
4728258750 | ||
|
|
ece3e16459 | ||
|
|
9208dbe2f3 | ||
|
|
636de18642 | ||
|
|
ee48456291 | ||
|
|
a06d5ffaed | ||
|
|
606821a2c0 | ||
|
|
5c980c21e5 | ||
|
|
771cc380df | ||
|
|
38217870fe | ||
|
|
5b82c96a43 | ||
|
|
6f5f9869ad | ||
|
|
b29bfeb8b1 | ||
|
|
cb40559e49 | ||
|
|
90c72f7ffe | ||
|
|
002bd2e6f7 | ||
|
|
7b44f17a64 | ||
|
|
b5c83f92e3 | ||
|
|
51b4d64a93 | ||
|
|
62c9e0a22f | ||
|
|
3daac34d3e | ||
|
|
d80d5d1632 | ||
|
|
912f716362 | ||
|
|
16ecdf41af | ||
|
|
f0bb6b08fa | ||
|
|
efb4e2ce9a | ||
|
|
03238d10bf | ||
|
|
e71590b9fb | ||
|
|
4728f1cc46 | ||
|
|
794778dee2 | ||
|
|
b5e882f07e | ||
|
|
e7c58a4847 | ||
|
|
bdb44db25e | ||
|
|
e8b82ffe62 | ||
|
|
53c53c009e |
20
.babelrc
20
.babelrc
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
"next/babel"
|
||||
],
|
||||
"plugins": [
|
||||
[
|
||||
"babel-plugin-transform-imports",
|
||||
{
|
||||
"@material-ui/core": {
|
||||
"transform": "@material-ui/core/${member}",
|
||||
"preventFullImport": true
|
||||
},
|
||||
"@material-ui/icons": {
|
||||
"transform": "@material-ui/icons/${member}",
|
||||
"preventFullImport": true
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.next/
|
||||
uploads/
|
||||
.git/
|
||||
.yarn/*
|
||||
!.yarn/releases
|
||||
!.yarn/plugins
|
||||
24
.eslintrc.js
24
.eslintrc.js
@@ -1,24 +0,0 @@
|
||||
module.exports = {
|
||||
'extends': ['next', 'next/core-web-vitals'],
|
||||
'rules': {
|
||||
'indent': ['error', 2],
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
'quotes': ['error', 'single'],
|
||||
'semi': ['error', 'always'],
|
||||
'jsx-quotes': ['error', 'prefer-single'],
|
||||
'react/prop-types': 'off',
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'react/jsx-uses-react': 'warn',
|
||||
'react/jsx-uses-vars': 'warn',
|
||||
'react/no-danger-with-children': 'warn',
|
||||
'react/no-deprecated': 'warn',
|
||||
'react/no-direct-mutation-state': 'warn',
|
||||
'react/no-is-mounted': 'warn',
|
||||
'react/no-typos': 'error',
|
||||
'react/react-in-jsx-scope': 'error',
|
||||
'react/require-render-return': 'error',
|
||||
'react/style-prop-object': 'warn',
|
||||
'@next/next/no-img-element': 'off'
|
||||
}
|
||||
};
|
||||
25
.eslintrc.json
Normal file
25
.eslintrc.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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"],
|
||||
"react/prop-types": "off",
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"react/jsx-uses-react": "warn",
|
||||
"react/jsx-uses-vars": "warn",
|
||||
"react/no-danger-with-children": "warn",
|
||||
"react/no-deprecated": "warn",
|
||||
"react/no-direct-mutation-state": "warn",
|
||||
"react/no-is-mounted": "warn",
|
||||
"react/no-typos": "error",
|
||||
"react/react-in-jsx-scope": "error",
|
||||
"react/require-render-return": "error",
|
||||
"react/style-prop-object": "warn",
|
||||
"@next/next/no-img-element": "off"
|
||||
}
|
||||
}
|
||||
9
.github/workflows/build.yml
vendored
9
.github/workflows/build.yml
vendored
@@ -23,12 +23,11 @@ jobs:
|
||||
key: ${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
|
||||
|
||||
- name: Create mock config
|
||||
run: echo -e "[uploader]\nroute = '/u'\nembed_route = '/a'\nlength = 6\ndirectory = './uploads'" > config.toml
|
||||
|
||||
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
|
||||
41
.github/workflows/docker-arm.yml
vendored
Normal file
41
.github/workflows/docker-arm.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: 'CD: Push ARM64 Docker Images'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ trunk ]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'server/**'
|
||||
- 'prisma/**'
|
||||
- '.github/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
push_to_ghcr:
|
||||
name: Push Image to GitHub Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Login to Github Packages
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- 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
|
||||
40
.github/workflows/docker.yml
vendored
40
.github/workflows/docker.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
- 'src/**'
|
||||
- 'server/**'
|
||||
- 'prisma/**'
|
||||
- '.github/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -17,28 +18,31 @@ jobs:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Push to GitHub Packages
|
||||
uses: docker/build-push-action@v1
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Login to Github Packages
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
registry: docker.pkg.github.com
|
||||
repository: diced/zipline/zipline
|
||||
dockerfile: Dockerfile
|
||||
tag_with_ref: true
|
||||
|
||||
push_to_dockerhub:
|
||||
name: Push Image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Push to Docker Hub
|
||||
uses: docker/build-push-action@v1
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
repository: diced/zipline
|
||||
dockerfile: Dockerfile
|
||||
tag_with_ref: true
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/diced/zipline/zipline:trunk
|
||||
ghcr.io/diced/zipline/amd64:trunk
|
||||
diced/zipline:trunk
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -5,6 +5,11 @@
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# yarn
|
||||
.yarn/*
|
||||
!.yarn/releases
|
||||
!.yarn/plugins
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
@@ -18,6 +23,7 @@
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.idea
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
@@ -36,4 +42,5 @@ yarn-error.log*
|
||||
# zipline
|
||||
config.toml
|
||||
uploads/
|
||||
data.db*
|
||||
dist/
|
||||
docker-compose.local.yml
|
||||
1
.husky/.gitignore
vendored
1
.husky/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
_
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
yarn commitlint --edit $1
|
||||
546
.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
Normal file
546
.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
Normal file
File diff suppressed because one or more lines are too long
786
.yarn/releases/yarn-3.2.1.cjs
vendored
Normal file
786
.yarn/releases/yarn-3.2.1.cjs
vendored
Normal file
File diff suppressed because one or more lines are too long
7
.yarnrc.yml
Normal file
7
.yarnrc.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
|
||||
spec: "@yarnpkg/plugin-interactive-tools"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.2.1.cjs
|
||||
42
Dockerfile
42
Dockerfile
@@ -1,31 +1,47 @@
|
||||
FROM node:16-alpine3.11 AS builder
|
||||
FROM node:16-alpine AS deps
|
||||
WORKDIR /build
|
||||
|
||||
COPY .yarn .yarn
|
||||
COPY package.json yarn.lock .yarnrc.yml ./
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN yarn install --immutable
|
||||
|
||||
FROM node:16-alpine AS builder
|
||||
WORKDIR /build
|
||||
|
||||
COPY --from=deps /build/node_modules ./node_modules
|
||||
COPY src ./src
|
||||
COPY server ./server
|
||||
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 next.config.js next-env.d.ts zip-env.d.ts tsconfig.json ./
|
||||
|
||||
RUN yarn install
|
||||
|
||||
# create a mock config.toml to spoof next build!
|
||||
RUN echo -e "[uploader]\nroute = '/u'\nembed_route = '/a'\nlength = 6\ndirectory = './uploads'" > config.toml
|
||||
ENV ZIPLINE_DOCKER_BUILD 1
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN yarn build
|
||||
|
||||
FROM node:16-alpine3.11 AS runner
|
||||
FROM node:16-alpine AS runner
|
||||
WORKDIR /zipline
|
||||
|
||||
COPY --from=builder /build/node_modules ./node_modules
|
||||
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/server ./server
|
||||
COPY --from=builder /build/scripts ./scripts
|
||||
COPY --from=builder /build/prisma ./prisma
|
||||
COPY --from=builder /build/.next ./.next
|
||||
COPY --from=builder /build/tsconfig.json ./tsconfig.json
|
||||
COPY --from=builder /build/package.json ./package.json
|
||||
|
||||
CMD ["node", "server"]
|
||||
USER zipline
|
||||
|
||||
CMD ["node", "dist/server"]
|
||||
46
Dockerfile-arm
Normal file
46
Dockerfile-arm
Normal file
@@ -0,0 +1,46 @@
|
||||
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"]
|
||||
@@ -1,3 +0,0 @@
|
||||
prisma/migrations
|
||||
node_modules
|
||||
.next
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 dicedtomato
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
124
README.md
124
README.md
@@ -1,25 +1,113 @@
|
||||
<p align="center"><img src="https://raw.githubusercontent.com/diced/zipline/trunk/public/zipline_small.png"/></p>
|
||||
|
||||

|
||||

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

|
||||

|
||||

|
||||
<br>
|
||||
|
||||
# Zipline
|
||||
|
||||
Fast & lightweight file uploading.
|
||||
|
||||
# Features
|
||||
<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
|
||||
- Configurable
|
||||
- Fast
|
||||
- Built with Next.js & React
|
||||
- Token protected uploading
|
||||
- Easy setup instructions on [docs](https://zipline.diced.me) (One command install `docker-compose up`)
|
||||
- Image uploading
|
||||
- Password Protected Uploads
|
||||
- URL shortening
|
||||
- Text uploading
|
||||
- URL Formats (uuid, dates, random alphanumeric, original name, zws)
|
||||
- Discord embeds (OG metadata)
|
||||
- Gallery viewer, and multiple file format support
|
||||
- Code highlighting
|
||||
- Easy setup instructions on [docs](https://zipl.vercel.app/) (One command install `docker-compose up -d`)
|
||||
|
||||
# Installing
|
||||
# Usage
|
||||
|
||||
[See how to install here](https://zipline.diced.me/docs/getting-started)
|
||||
## Install & run with Docker
|
||||
This section requires [Docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/).
|
||||
|
||||
```shell
|
||||
git clone https://github.com/diced/zipline
|
||||
cd zipline
|
||||
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### After installing
|
||||
After installing, please edit the `docker-compose.yml` file and find the line that says `SECRET=changethis` and replace `changethis` with a random string.
|
||||
Ways you could generate the string could be from a password managers generator, or you could just slam your keyboard and hope for the best.
|
||||
|
||||
## Building & running from source
|
||||
This section requires [nodejs](https://nodejs.org), [yarn](https://yarnpkg.com/) or [npm](https://npmjs.com).
|
||||
```shell
|
||||
git clone https://github.com/diced/zipline
|
||||
cd zipline
|
||||
|
||||
# npm install
|
||||
yarn install
|
||||
# npm run build
|
||||
yarn build
|
||||
# npm start
|
||||
yarn start
|
||||
```
|
||||
|
||||
# NGINX Proxy
|
||||
This section requires [nginx](https://nginx.org/).
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80 default_server;
|
||||
client_max_body_size 100M;
|
||||
server_name <your domain (optional)>;
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# Website
|
||||
The default port is `3000`, once you have accessed it you can see a login screen. The default credentials are "administrator" and "password". Once you login please immediately change the details to something more secure. You can do this by clicking on the top right corner where it says "administrator" with a gear icon and clicking Manage Account.
|
||||
|
||||
# ShareX (Windows)
|
||||
This section requires [ShareX](https://www.getsharex.com/).
|
||||
|
||||
After navigating to Zipline, click on the top right corner where it says your username and click Manage Account. Scroll down to see "ShareX Config", select the one you would prefer using. After this you can import the .sxcu into sharex. [More information here](https://zipl.vercel.app/docs/uploaders/sharex)
|
||||
|
||||
# Flameshot (Linux)
|
||||
This section requires [Flameshot](https://www.flameshot.org/), [jq](https://stedolan.github.io/jq/) and 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.
|
||||
|
||||
```shell
|
||||
DATE=$(date '+%h_%Y_%d_%I_%m_%S.png');
|
||||
flameshot gui -r > ~/Pictures/$DATE;
|
||||
|
||||
curl -H "Content-Type: multipart/form-data" -H "authorization: $TOKEN" -F file=@$1 $HOST/api/upload | jq -r 'files[0].url' | xsel -ib
|
||||
```
|
||||
|
||||
# Contributing
|
||||
|
||||
## Bug reports
|
||||
Create an issue on GitHub, please include the following:
|
||||
* The steps to reproduce the bug
|
||||
* Logs of Zipline
|
||||
* The version of Zipline
|
||||
* Your OS & Browser including server OS
|
||||
* What you were expecting to see
|
||||
|
||||
## Feature requests
|
||||
Create an issue on GitHub, please include the following:
|
||||
* Breif explanation of the feature in the title (very breif please)
|
||||
* How it would work (detailed, but optional)
|
||||
|
||||
## Pull Requests (contributions to the codebase)
|
||||
Create a pull request on GitHub. If your PR does not pass the action checks, then please fix the errors. If your PR was submitted before a release, and I have pushed a new release, please make sure to update your PR to reflect any changes, usually this is handled by GitHub.
|
||||
12
SECURITY.md
Normal file
12
SECURITY.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 3.4.4 | :white_check_mark: |
|
||||
| < 3 | :x: |
|
||||
| < 2 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
Report a Vulnerability by issuing a bug report, with exact details with how the vulnerability happened, what "exploits" can happen, and possible fixes (optional). Vulnerability reports are treated with high priority and will be resolved most of the time quickly.
|
||||
@@ -1,53 +0,0 @@
|
||||
module.exports = {
|
||||
parserPreset: 'conventional-changelog-conventionalcommits',
|
||||
rules: {
|
||||
'body-leading-blank': [1, 'always'],
|
||||
'body-max-line-length': [2, 'always', 100],
|
||||
'footer-leading-blank': [1, 'always'],
|
||||
'footer-max-line-length': [2, 'always', 100],
|
||||
'header-max-length': [2, 'always', 100],
|
||||
'subject-case': [
|
||||
2,
|
||||
'never',
|
||||
['sentence-case', 'start-case', 'pascal-case', 'upper-case'],
|
||||
],
|
||||
'subject-empty': [2, 'never'],
|
||||
'subject-full-stop': [2, 'never', '.'],
|
||||
'type-case': [2, 'always', 'lower-case'],
|
||||
'type-empty': [2, 'never'],
|
||||
'type-enum': [
|
||||
2,
|
||||
'always',
|
||||
[
|
||||
'build',
|
||||
'chore',
|
||||
'ci',
|
||||
'docs',
|
||||
'feat',
|
||||
'fix',
|
||||
'perf',
|
||||
'refactor',
|
||||
'revert',
|
||||
'style',
|
||||
'test',
|
||||
],
|
||||
],
|
||||
'scope-enum': [
|
||||
1,
|
||||
'always',
|
||||
[
|
||||
'prisma',
|
||||
'scripts',
|
||||
'server',
|
||||
'pages',
|
||||
'api',
|
||||
'hooks',
|
||||
'components',
|
||||
'middleware',
|
||||
'redux',
|
||||
'themes',
|
||||
'lib'
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,15 +1,19 @@
|
||||
[core]
|
||||
secure = true
|
||||
secret = 'some secret'
|
||||
secret = 'changethis'
|
||||
host = '0.0.0.0'
|
||||
port = 3000
|
||||
database_url = 'postgres://postgres:postgres@postgres/postgres'
|
||||
|
||||
[database]
|
||||
type = 'psql'
|
||||
url = 'postgres://postgres:postgres@postgres/postgres'
|
||||
[urls]
|
||||
route = '/go'
|
||||
length = 6
|
||||
|
||||
[uploader]
|
||||
route = '/u'
|
||||
embed_route = '/a'
|
||||
length = 6
|
||||
directory = './uploads'
|
||||
directory = './uploads'
|
||||
user_limit = 104900000 # 100mb
|
||||
admin_limit = 104900000 # 100mb
|
||||
disabled_extensions = ['jpg']
|
||||
|
||||
46
docker-compose.arm.yml
Normal file
46
docker-compose.arm.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
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:
|
||||
48
docker-compose.dev.yml
Normal file
48
docker-compose.dev.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
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:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
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:
|
||||
@@ -2,11 +2,12 @@ version: '3'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
environment:
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DATABASE=postgres
|
||||
volumes:
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
||||
@@ -15,24 +16,28 @@ services:
|
||||
retries: 5
|
||||
|
||||
zipline:
|
||||
image: diced/zipline:trunk
|
||||
image: ghcr.io/diced/zipline/zipline:trunk
|
||||
ports:
|
||||
- '3000:3000'
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
restart: always
|
||||
environment:
|
||||
- SECURE=false
|
||||
- SECRET=changethis
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- DATABASE_TYPE=psql
|
||||
- DATASOURCE_TYPE=local
|
||||
- DATASOURCE_DIRECTORY=./uploads
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
|
||||
- UPLOADER_ROUTE=/u
|
||||
- UPLOADER_EMBED_ROUTE=/a
|
||||
- UPLOADER_LENGTH=6
|
||||
- UPLOADER_DIRECTORY=./uploads
|
||||
- UPLOADER_ADMIN_LIMIT=104900000
|
||||
- UPLOADER_USER_LIMIT=104900000
|
||||
- UPLOADER_DISABLED_EXTS=
|
||||
- URLS_ROUTE=/go
|
||||
- URLS_LENGTH=6
|
||||
volumes:
|
||||
- '$PWD/uploads:/zipline/uploads'
|
||||
- '$PWD/prisma:/zipline/prisma'
|
||||
- '$PWD/public:/zipline/public'
|
||||
depends_on:
|
||||
- 'postgres'
|
||||
|
||||
40
esbuild.config.js
Normal file
40
esbuild.config.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const esbuild = require('esbuild');
|
||||
const { existsSync } = require('fs');
|
||||
const { rm } = require('fs/promises');
|
||||
|
||||
(async () => {
|
||||
const watch = process.argv[2] === '--watch';
|
||||
|
||||
if (existsSync('./dist')) {
|
||||
await rm('./dist', { recursive: true });
|
||||
}
|
||||
|
||||
await esbuild.build({
|
||||
tsconfig: 'tsconfig.json',
|
||||
outdir: 'dist',
|
||||
bundle: false,
|
||||
platform: 'node',
|
||||
treeShaking: true,
|
||||
entryPoints: [
|
||||
'src/server/index.ts',
|
||||
'src/server/util.ts',
|
||||
'src/lib/logger.ts',
|
||||
'src/lib/config.ts',
|
||||
'src/lib/config/Config.ts',
|
||||
'src/lib/config/readConfig.ts',
|
||||
'src/lib/config/validateConfig.ts',
|
||||
'src/lib/datasources/Datasource.ts',
|
||||
'src/lib/datasources/index.ts',
|
||||
'src/lib/datasources/Local.ts',
|
||||
'src/lib/datasources/S3.ts',
|
||||
'src/lib/datasource.ts',
|
||||
],
|
||||
format: 'cjs',
|
||||
resolveExtensions: ['.ts', '.js'],
|
||||
write: true,
|
||||
watch,
|
||||
incremental: watch,
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
});
|
||||
})();
|
||||
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
@@ -1,5 +1,4 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/types/global" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
module.exports = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/',
|
||||
destination: '/dashboard',
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
api: {
|
||||
responseLimit: false,
|
||||
},
|
||||
};
|
||||
100
package.json
100
package.json
@@ -1,62 +1,68 @@
|
||||
{
|
||||
"name": "zip3",
|
||||
"version": "3.2.0",
|
||||
"name": "zipline",
|
||||
"version": "3.4.6",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"prepare": "husky install",
|
||||
"dev": "NODE_ENV=development node server",
|
||||
"build": "npm-run-all build:schema build:next",
|
||||
"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",
|
||||
"build:next": "next build",
|
||||
"build:schema": "prisma generate --schema=prisma/schema.prisma",
|
||||
"start": "node server",
|
||||
"migrate:dev": "prisma migrate dev --create-only",
|
||||
"start": "node dist/server",
|
||||
"lint": "next lint",
|
||||
"ts-node": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only",
|
||||
"create-all-migrations": "node scripts/create-migrations",
|
||||
"semantic-release": "semantic-release"
|
||||
"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.4.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@iarna/toml": "2.2.5",
|
||||
"@material-ui/core": "^5.0.0-alpha.37",
|
||||
"@material-ui/data-grid": "^4.0.0-alpha.32",
|
||||
"@material-ui/icons": "^5.0.0-alpha.37",
|
||||
"@material-ui/styles": "^5.0.0-alpha.35",
|
||||
"@prisma/client": "^2.30.0",
|
||||
"@reduxjs/toolkit": "^1.6.0",
|
||||
"argon2": "^0.28.2",
|
||||
"colorette": "^1.2.2",
|
||||
"cookie": "^0.4.1",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"fecha": "^4.2.1",
|
||||
"formik": "^2.2.9",
|
||||
"multer": "^1.4.2",
|
||||
"next": "11.1.0",
|
||||
"prisma": "^2.30.0",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-dropzone": "^11.3.2",
|
||||
"react-redux": "^7.2.4",
|
||||
"redux": "^4.1.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"yup": "^0.32.9"
|
||||
"@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",
|
||||
"@reduxjs/toolkit": "^1.8.2",
|
||||
"argon2": "^0.28.5",
|
||||
"aws-sdk": "^2.1156.0",
|
||||
"colorette": "^2.0.19",
|
||||
"cookie": "^0.5.0",
|
||||
"fecha": "^4.2.3",
|
||||
"fflate": "^0.7.3",
|
||||
"find-my-way": "^6.3.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"next": "^12.1.6",
|
||||
"prisma": "^3.15.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^8.0.2",
|
||||
"react-table": "^7.8.0",
|
||||
"redux": "^4.2.0",
|
||||
"redux-thunk": "^2.4.1",
|
||||
"uuid": "^8.3.2",
|
||||
"yup": "^0.32.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^12.1.4",
|
||||
"@commitlint/config-conventional": "^12.1.4",
|
||||
"@types/cookie": "^0.4.0",
|
||||
"@types/multer": "^1.4.6",
|
||||
"@types/cookie": "^0.5.1",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^15.12.2",
|
||||
"babel-plugin-transform-imports": "^2.0.0",
|
||||
"eslint": "7.28.0",
|
||||
"eslint-config-next": "11.0.0",
|
||||
"husky": "^6.0.0",
|
||||
"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",
|
||||
"release": "^6.3.0",
|
||||
"ts-node": "^10.0.0",
|
||||
"typescript": "^4.3.2"
|
||||
"ts-node": "^10.8.1",
|
||||
"typescript": "^4.7.3"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/diced/workflow-testing.git"
|
||||
}
|
||||
}
|
||||
"url": "https://github.com/diced/zipline.git"
|
||||
},
|
||||
"packageManager": "yarn@3.2.1"
|
||||
}
|
||||
2
prisma/migrations/20210827202147_favorite/migration.sql
Normal file
2
prisma/migrations/20210827202147_favorite/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" ADD COLUMN "favorite" BOOLEAN NOT NULL DEFAULT false;
|
||||
25
prisma/migrations/20210830210159_zws/migration.sql
Normal file
25
prisma/migrations/20210830210159_zws/migration.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[imageId]` on the table `InvisibleImage` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `imageId` to the `InvisibleImage` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "InvisibleImage" DROP CONSTRAINT "InvisibleImage_id_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "InvisibleImage_id_unique";
|
||||
|
||||
-- AlterTable
|
||||
CREATE SEQUENCE "invisibleimage_id_seq";
|
||||
ALTER TABLE "InvisibleImage" ADD COLUMN "imageId" INTEGER NOT NULL,
|
||||
ALTER COLUMN "id" SET DEFAULT nextval('invisibleimage_id_seq'),
|
||||
ADD PRIMARY KEY ("id");
|
||||
ALTER SEQUENCE "invisibleimage_id_seq" OWNED BY "InvisibleImage"."id";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InvisibleImage_imageId_unique" ON "InvisibleImage"("imageId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvisibleImage" ADD FOREIGN KEY ("imageId") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" ADD COLUMN "embed" BOOLEAN NOT NULL DEFAULT false;
|
||||
39
prisma/migrations/20210924045900_delete_url/migration.sql
Normal file
39
prisma/migrations/20210924045900_delete_url/migration.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `InvisibleUrl` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `Url` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Image" DROP CONSTRAINT "Image_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "InvisibleImage" DROP CONSTRAINT "InvisibleImage_imageId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "InvisibleUrl" DROP CONSTRAINT "InvisibleUrl_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Theme" DROP CONSTRAINT "Theme_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Url" DROP CONSTRAINT "Url_userId_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "InvisibleUrl";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Url";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Theme" ADD CONSTRAINT "Theme_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Image" ADD CONSTRAINT "Image_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvisibleImage" ADD CONSTRAINT "InvisibleImage_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Image"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "InvisibleImage.invis_unique" RENAME TO "InvisibleImage_invis_key";
|
||||
34
prisma/migrations/20210924050753_new_url/migration.sql
Normal file
34
prisma/migrations/20210924050753_new_url/migration.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Url" (
|
||||
"id" TEXT NOT NULL,
|
||||
"destination" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"views" INTEGER NOT NULL DEFAULT 0,
|
||||
"userId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Url_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "InvisibleUrl" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"invis" TEXT NOT NULL,
|
||||
"urlId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "InvisibleUrl_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Url_id_key" ON "Url"("id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InvisibleUrl_invis_key" ON "InvisibleUrl"("invis");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InvisibleUrl_urlId_unique" ON "InvisibleUrl"("urlId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Url" ADD CONSTRAINT "Url_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvisibleUrl" ADD CONSTRAINT "InvisibleUrl_urlId_fkey" FOREIGN KEY ("urlId") REFERENCES "Url"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Url" ADD COLUMN "vanity" TEXT;
|
||||
2
prisma/migrations/20211003022626_site_name/migration.sql
Normal file
2
prisma/migrations/20211003022626_site_name/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "embedSiteName" TEXT DEFAULT E'{image.file} • {user.name}';
|
||||
11
prisma/migrations/20211128031800_ratelimit/migration.sql
Normal file
11
prisma/migrations/20211128031800_ratelimit/migration.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "ratelimited" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "InvisibleImage_imageId_unique" RENAME TO "InvisibleImage_imageId_key";
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "InvisibleUrl_urlId_unique" RENAME TO "InvisibleUrl_urlId_key";
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "Theme_userId_unique" RENAME TO "Theme_userId_key";
|
||||
8
prisma/migrations/20220103232702_stats/migration.sql
Normal file
8
prisma/migrations/20220103232702_stats/migration.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Stats" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"data" JSONB NOT NULL,
|
||||
|
||||
CONSTRAINT "Stats_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
5
prisma/migrations/20220221053815_format/migration.sql
Normal file
5
prisma/migrations/20220221053815_format/migration.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ImageFormat" AS ENUM ('UUID', 'DATE', 'RANDOM');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" ADD COLUMN "format" "ImageFormat" NOT NULL DEFAULT E'RANDOM';
|
||||
2
prisma/migrations/20220221055817_name/migration.sql
Normal file
2
prisma/migrations/20220221055817_name/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ImageFormat" ADD VALUE 'NAME';
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
Warnings:
|
||||
- You are about to drop the `Theme` table. If the table is not empty, all the data it contains will be lost.
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Theme" DROP CONSTRAINT "Theme_userId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "systemTheme" SET DEFAULT E'system';
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Theme";
|
||||
2
prisma/migrations/20220304004623_domains/migration.sql
Normal file
2
prisma/migrations/20220304004623_domains/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "domains" TEXT[];
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" ADD COLUMN "password" TEXT;
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `ratelimited` on the `User` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "ratelimited",
|
||||
ADD COLUMN "ratelimit" TIMESTAMP(3);
|
||||
@@ -8,32 +8,26 @@ generator client {
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
username String
|
||||
password String
|
||||
token String
|
||||
administrator Boolean @default(false)
|
||||
systemTheme String @default("dark_blue")
|
||||
customTheme Theme?
|
||||
administrator Boolean @default(false)
|
||||
systemTheme String @default("system")
|
||||
embedTitle String?
|
||||
embedColor String @default("#2f3136")
|
||||
embedColor String @default("#2f3136")
|
||||
embedSiteName String? @default("{image.file} • {user.name}")
|
||||
ratelimit DateTime?
|
||||
domains String[]
|
||||
images Image[]
|
||||
urls Url[]
|
||||
}
|
||||
|
||||
model Theme {
|
||||
id Int @id @default(autoincrement())
|
||||
type String
|
||||
primary String
|
||||
secondary String
|
||||
error String
|
||||
warning String
|
||||
info String
|
||||
border String
|
||||
mainBackground String
|
||||
paperBackground String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
enum ImageFormat {
|
||||
UUID
|
||||
DATE
|
||||
RANDOM
|
||||
NAME
|
||||
}
|
||||
|
||||
model Image {
|
||||
@@ -42,31 +36,42 @@ model Image {
|
||||
mimetype String @default("image/png")
|
||||
created_at DateTime @default(now())
|
||||
views Int @default(0)
|
||||
favorite Boolean @default(false)
|
||||
embed Boolean @default(false)
|
||||
password String?
|
||||
invisible InvisibleImage?
|
||||
format ImageFormat @default(RANDOM)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
}
|
||||
|
||||
model InvisibleImage {
|
||||
id Int
|
||||
image Image @relation(fields: [id], references: [id])
|
||||
|
||||
invis String @unique
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
imageId Int @unique
|
||||
image Image @relation(fields: [imageId], references: [id])
|
||||
}
|
||||
|
||||
model Url {
|
||||
id Int @id @default(autoincrement())
|
||||
to String
|
||||
created_at DateTime @default(now())
|
||||
views Int @default(0)
|
||||
invisible InvisibleUrl?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
id String @id @unique
|
||||
destination String
|
||||
vanity String?
|
||||
created_at DateTime @default(now())
|
||||
views Int @default(0)
|
||||
invisible InvisibleUrl?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
}
|
||||
|
||||
model InvisibleUrl {
|
||||
id Int
|
||||
url Url @relation(fields: [id], references: [id])
|
||||
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
urlId String @unique
|
||||
url Url @relation(fields: [urlId], references: [id])
|
||||
}
|
||||
|
||||
model Stats {
|
||||
id Int @id @default(autoincrement())
|
||||
created_at DateTime @default(now())
|
||||
data Json
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ async function main() {
|
||||
username: 'administrator',
|
||||
password: await hashPassword('password'),
|
||||
token: createToken(),
|
||||
administrator: true
|
||||
}
|
||||
administrator: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 279 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 4.8 KiB |
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
branches: ['trunk'],
|
||||
plugins: [
|
||||
'@semantic-release/commit-analyzer',
|
||||
'@semantic-release/github',
|
||||
'@semantic-release/changelog'
|
||||
]
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
const Logger = require('../src/lib/logger');
|
||||
const prismaRun = require('./prisma-run');
|
||||
|
||||
module.exports = async (config) => {
|
||||
try {
|
||||
await prismaRun(config.database.url, ['migrate', 'deploy']);
|
||||
await prismaRun(config.database.url, ['generate']);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
Logger.get('db').error('there was an error.. exiting..');
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
38
scripts/exts.js
Normal file
38
scripts/exts.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// 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',
|
||||
};
|
||||
@@ -11,7 +11,7 @@ const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
await validateConfig(config);
|
||||
|
||||
process.env.DATABASE_URL = config.database.url;
|
||||
process.env.DATABASE_URL = config.core.database_url;
|
||||
|
||||
const files = await readdir(process.argv[2]);
|
||||
const data = files.map(x => {
|
||||
@@ -20,7 +20,7 @@ const { PrismaClient } = require('@prisma/client');
|
||||
return {
|
||||
file: x,
|
||||
mimetype: mime,
|
||||
userId: 1
|
||||
userId: 1,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
Logger.get('migrator').info('starting migrations...');
|
||||
await prisma.image.createMany({
|
||||
data
|
||||
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();
|
||||
|
||||
@@ -74,5 +74,5 @@ module.exports = {
|
||||
'.zip': 'application/zip',
|
||||
'.3gp': 'video/3gpp',
|
||||
'.3g2': 'video/3gpp2',
|
||||
'.7z': 'application/x-7z-compressed'
|
||||
'.7z': 'application/x-7z-compressed',
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
const { spawn } = require('child_process');
|
||||
const { join } = require('path');
|
||||
|
||||
module.exports = (url, args) => {
|
||||
return new Promise((res, rej) => {
|
||||
const proc = spawn(join(process.cwd(), 'node_modules', '.bin', 'prisma'), args, {
|
||||
env: {
|
||||
DATABASE_URL: url,
|
||||
...process.env
|
||||
},
|
||||
});
|
||||
|
||||
let a = '';
|
||||
|
||||
proc.stdout.on('data', d => {
|
||||
console.log(d.toString());
|
||||
a += d.toString();
|
||||
});
|
||||
proc.stderr.on('data', d => {
|
||||
console.log(d.toString());
|
||||
rej(d.toString());
|
||||
});
|
||||
proc.stdout.on('end', () => res(a));
|
||||
proc.stdout.on('close', () => res(a));
|
||||
});
|
||||
};
|
||||
129
server/index.js
129
server/index.js
@@ -1,129 +0,0 @@
|
||||
const next = require('next');
|
||||
const { createServer } = require('http');
|
||||
const { stat, mkdir } = require('fs/promises');
|
||||
const { execSync } = require('child_process');
|
||||
const { extname } = require('path');
|
||||
const { red, green, bold } = require('colorette');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const validateConfig = require('./validateConfig');
|
||||
const Logger = require('../src/lib/logger');
|
||||
const getFile = require('./static');
|
||||
const prismaRun = require('../scripts/prisma-run');
|
||||
const readConfig = require('../src/lib/readConfig');
|
||||
const mimes = require('../scripts/mimes');
|
||||
const deployDb = require('../scripts/deploy-db');
|
||||
|
||||
|
||||
Logger.get('server').info('starting zipline server');
|
||||
|
||||
const dev = process.env.NODE_ENV === 'development';
|
||||
|
||||
function log(url, status) {
|
||||
if (url.startsWith('/_next') || url.startsWith('/__nextjs')) return;
|
||||
return Logger.get('url').info(`${status === 200 ? bold(green(status)) : bold(red(status))}: ${url}`);
|
||||
}
|
||||
|
||||
function shouldUseYarn() {
|
||||
try {
|
||||
execSync('yarnpkg --version', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
await validateConfig(config);
|
||||
|
||||
const data = await prismaRun(config.database.url, ['migrate', 'status']);
|
||||
if (data.includes('Following migration have not yet been applied:')) {
|
||||
Logger.get('database').info('some migrations are not applied, applying them now...');
|
||||
await deployDb(config);
|
||||
Logger.get('database').info('finished applying migrations');
|
||||
}
|
||||
process.env.DATABASE_URL = config.database.url;
|
||||
|
||||
await stat('./.next');
|
||||
await mkdir(config.uploader.directory, { recursive: true });
|
||||
|
||||
const app = next({
|
||||
dir: '.',
|
||||
dev,
|
||||
quiet: dev
|
||||
}, config.core.port, config.core.host);
|
||||
|
||||
await app.prepare();
|
||||
|
||||
const handle = app.getRequestHandler();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const srv = createServer(async (req, res) => {
|
||||
if (req.url.startsWith(config.uploader.route)) {
|
||||
const parts = req.url.split('/');
|
||||
if (!parts[2] || parts[2] === '') return;
|
||||
|
||||
const data = await getFile(config.uploader.directory, parts[2]);
|
||||
if (!data) {
|
||||
app.render404(req, res);
|
||||
} else {
|
||||
let image = await prisma.image.findFirst({
|
||||
where: {
|
||||
OR: {
|
||||
file: parts[2],
|
||||
},
|
||||
OR: {
|
||||
invisible: {
|
||||
invis: decodeURI(parts[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (image) {
|
||||
await prisma.image.update({
|
||||
where: {
|
||||
id: image.id,
|
||||
},
|
||||
data: {
|
||||
views: {
|
||||
increment: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
res.setHeader('Content-Type', image.mimetype);
|
||||
} else {
|
||||
const mimetype = mimes[extname(parts[2])] ?? 'application/octet-stream';
|
||||
res.setHeader('Content-Type', mimetype);
|
||||
}
|
||||
|
||||
res.end(data);
|
||||
}
|
||||
} else {
|
||||
handle(req, res);
|
||||
}
|
||||
|
||||
log(req.url, res.statusCode);
|
||||
});
|
||||
|
||||
srv.on('error', (e) => {
|
||||
Logger.get('server').error(e);
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
srv.on('listening', () => {
|
||||
Logger.get('server').info(`listening on ${config.core.host}:${config.core.port}`);
|
||||
});
|
||||
|
||||
srv.listen(config.core.port, config.core.host ?? '0.0.0.0');
|
||||
} catch (e) {
|
||||
if (e.message && e.message.startsWith('Could not find a production')) {
|
||||
Logger.get('web').error(`there is no production build - run \`${shouldUseYarn() ? 'yarn build' : 'npm build'}\``);
|
||||
} else if (e.code && e.code === 'ENOENT') {
|
||||
if (e.path === './.next') Logger.get('web').error(`there is no production build - run \`${shouldUseYarn() ? 'yarn build' : 'npm build'}\``);
|
||||
} else {
|
||||
Logger.get('server').error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -1,11 +0,0 @@
|
||||
const { readFile } = require('fs/promises');
|
||||
const { join } = require('path');
|
||||
|
||||
module.exports = async (dir, file) => {
|
||||
try {
|
||||
const data = await readFile(join(process.cwd(), dir, file));
|
||||
return data;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
const Logger = require('../src/lib/logger');
|
||||
|
||||
function dot(str, obj) {
|
||||
return str.split('.').reduce((a,b) => a[b], obj);
|
||||
}
|
||||
|
||||
const path = (path, type) => ({ path, type });
|
||||
|
||||
module.exports = async config => {
|
||||
const paths = [
|
||||
path('core.secure', 'boolean'),
|
||||
path('core.secret', 'string'),
|
||||
path('core.host', 'string'),
|
||||
path('core.port', 'number'),
|
||||
path('database.type', 'string'),
|
||||
path('database.url', 'string'),
|
||||
path('uploader.route', 'string'),
|
||||
path('uploader.embed_route', 'string'),
|
||||
path('uploader.length', 'number'),
|
||||
path('uploader.directory', 'string')
|
||||
];
|
||||
|
||||
let errors = 0;
|
||||
|
||||
for (let i = 0, L = paths.length; i !== L; ++i) {
|
||||
const path = paths[i];
|
||||
const value = dot(path.path, config);
|
||||
if (value === undefined) {
|
||||
Logger.get('config').error(`there was no ${path.path} in config`);
|
||||
++errors;
|
||||
}
|
||||
const type = typeof value;
|
||||
|
||||
if (value !== undefined && type !== path.type) {
|
||||
Logger.get('config').error(`expected ${path.type} on ${path.path}, but got ${type}`);
|
||||
++errors;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors !== 0) {
|
||||
Logger.get('config').error(`exiting due to ${errors} errors`);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Snackbar, Alert as MuiAlert } from '@material-ui/core';
|
||||
|
||||
export default function Alert({ open, setOpen, severity, message }) {
|
||||
return (
|
||||
<Snackbar open={open} autoHideDuration={6000} anchorOrigin={{ vertical: 'top', horizontal: 'center' }} onClose={() => setOpen(false)}>
|
||||
<MuiAlert severity={severity} sx={{ width: '100%' }}>
|
||||
{message}
|
||||
</MuiAlert>
|
||||
</Snackbar>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,8 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Backdrop as MuiBackdrop,
|
||||
CircularProgress
|
||||
} from '@material-ui/core';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
|
||||
export default function Backdrop({ open }) {
|
||||
return (
|
||||
<MuiBackdrop
|
||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||
open={open}
|
||||
>
|
||||
<CircularProgress color='inherit' />
|
||||
</MuiBackdrop>
|
||||
<LoadingOverlay visible={open} />
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,16 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card as MuiCard,
|
||||
CardContent,
|
||||
Typography
|
||||
} from '@material-ui/core';
|
||||
Card as MCard,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
|
||||
export default function Card(props) {
|
||||
const { name, children, ...other } = props;
|
||||
|
||||
return (
|
||||
<MuiCard sx={{ minWidth: 100 }} {...other}>
|
||||
<CardContent>
|
||||
<Typography variant='h3'>{name}</Typography>
|
||||
{children}
|
||||
</CardContent>
|
||||
</MuiCard>
|
||||
<MCard padding='md' shadow='sm' {...other}>
|
||||
<Title order={2}>{name}</Title>
|
||||
{children}
|
||||
</MCard>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@material-ui/core';
|
||||
|
||||
export default function CenteredBox({ children, ...other }) {
|
||||
return (
|
||||
<Box
|
||||
justifyContent='center'
|
||||
display='flex'
|
||||
alignItems='center'
|
||||
{...other}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +1,94 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardMedia,
|
||||
CardActionArea,
|
||||
Popover,
|
||||
Button,
|
||||
ButtonGroup
|
||||
} from '@material-ui/core';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import useFetch from '../lib/hooks/useFetch';
|
||||
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 [anchorEl, setAnchorEl] = useState(null);
|
||||
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/images', 'DELETE', { id: image.id });
|
||||
if (!res.error) updateImages();
|
||||
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 />,
|
||||
});
|
||||
}
|
||||
|
||||
setAnchorEl(null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
copy(`${window.location.protocol}//${window.location.host}${image.url}`);
|
||||
setAnchorEl(null);
|
||||
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 (
|
||||
<>
|
||||
<Card sx={{ maxWidth: '100%' }}>
|
||||
<CardActionArea>
|
||||
<CardMedia
|
||||
sx={{ height: 320 }}
|
||||
image={image.url}
|
||||
title={image.file}
|
||||
onClick={e => setAnchorEl(e.currentTarget)}
|
||||
/>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Title>{image.file}</Title>}
|
||||
>
|
||||
<ButtonGroup variant='contained'>
|
||||
<Button onClick={handleDelete} color='primary'>Delete</Button>
|
||||
<Button onClick={handleCopy} color='primary'>Copy URL</Button>
|
||||
</ButtonGroup>
|
||||
</Popover>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
158
src/components/ImagesTable.tsx
Normal file
158
src/components/ImagesTable.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/* 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,
|
||||
Select,
|
||||
Table,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
CopyIcon,
|
||||
EnterIcon,
|
||||
TrashIcon,
|
||||
} from '@modulz/radix-icons';
|
||||
|
||||
const pageSizeOptions = ['10', '25', '50'];
|
||||
|
||||
const useStyles = createStyles((t) => ({
|
||||
root: { height: '100%', display: 'block', marginTop: 10 },
|
||||
tableContainer: {
|
||||
display: 'block',
|
||||
overflow: 'auto',
|
||||
'& > table': {
|
||||
'& > thead': { backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0], zIndex: 1 },
|
||||
'& > thead > tr > th': { padding: t.spacing.md },
|
||||
'& > tbody > tr > td': { padding: t.spacing.md },
|
||||
},
|
||||
borderRadius: 6,
|
||||
},
|
||||
stickHeader: { top: 0, position: 'sticky' },
|
||||
disableSortIcon: { color: t.colors.gray[5] },
|
||||
sortDirectionIcon: { transition: 'transform 200ms ease' },
|
||||
}));
|
||||
|
||||
export default function ImagesTable({
|
||||
columns,
|
||||
data = [],
|
||||
serverSideDataSource = false,
|
||||
initialPageSize = 10,
|
||||
initialPageIndex = 0,
|
||||
pageCount = 0,
|
||||
total = 0,
|
||||
deleteImage, copyImage, viewImage,
|
||||
}) {
|
||||
const { classes } = useStyles();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const tableOptions = useTable(
|
||||
{
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
initialState: { pageSize: initialPageSize, pageIndex: initialPageIndex },
|
||||
},
|
||||
usePagination
|
||||
);
|
||||
|
||||
const {
|
||||
getTableProps, getTableBodyProps, headerGroups, rows, prepareRow, page, gotoPage, setPageSize, state: { pageIndex, pageSize },
|
||||
} = tableOptions;
|
||||
|
||||
const getPageRecordInfo = () => {
|
||||
const firstRowNum = pageIndex * pageSize + 1;
|
||||
const totalRows = serverSideDataSource ? total : 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 handlePageChange = (pageNum) => gotoPage(pageNum - 1);
|
||||
|
||||
const renderHeader = () => headerGroups.map(hg => (
|
||||
<tr {...hg.getHeaderGroupProps()}>
|
||||
{hg.headers.map(column => (
|
||||
<th {...column.getHeaderProps()}>
|
||||
<Group noWrap position={column.align || 'apart'}>
|
||||
<div>{column.render('Header')}</div>
|
||||
</Group>
|
||||
</th>
|
||||
))}
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
));
|
||||
|
||||
const renderRow = rows => rows.map(row => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
<tr {...row.getRowProps()}>
|
||||
{row.cells.map(cell => (
|
||||
<td align={cell.column.align || 'left'} {...cell.getCellProps()}>
|
||||
{cell.render('Cell')}
|
||||
</td>
|
||||
))}
|
||||
<td align='right'>
|
||||
<Group noWrap>
|
||||
<ActionIcon color='red' variant='outline' onClick={() => deleteImage(row)}><TrashIcon /></ActionIcon>
|
||||
<ActionIcon color='primary' variant='outline' onClick={() => copyImage(row)}><CopyIcon /></ActionIcon>
|
||||
<ActionIcon color='green' variant='outline' onClick={() => viewImage(row)}><EnterIcon /></ActionIcon>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div
|
||||
className={classes.tableContainer}
|
||||
style={{ height: 'calc(100% - 44px)' }}
|
||||
>
|
||||
<Table {...getTableProps()}>
|
||||
<thead style={{ backgroundColor: theme.other.hover }}>
|
||||
{renderHeader()}
|
||||
</thead>
|
||||
|
||||
<tbody {...getTableBodyProps()}>
|
||||
{renderRow(page)}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<Divider mb='md' variant='dotted' />
|
||||
<Group position='left'>
|
||||
<Text size='sm'>Rows per page: </Text>
|
||||
<Select
|
||||
style={{ width: '72px' }}
|
||||
variant='filled'
|
||||
data={pageSizeOptions}
|
||||
value={pageSize + ''}
|
||||
onChange={pageSize => setPageSize(Number(pageSize))} />
|
||||
<Divider orientation='vertical' />
|
||||
|
||||
<Text size='sm'>{getPageRecordInfo()}</Text>
|
||||
<Divider orientation='vertical' />
|
||||
|
||||
<Pagination
|
||||
page={pageIndex + 1}
|
||||
total={getPageCount()}
|
||||
onChange={handlePageChange} />
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,417 +1,333 @@
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import useFetch from '../lib/hooks/useFetch';
|
||||
|
||||
import {
|
||||
AppBar,
|
||||
Box,
|
||||
Divider,
|
||||
Drawer,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Select,
|
||||
} from '@material-ui/core';
|
||||
import {
|
||||
Menu as MenuIcon,
|
||||
Home as HomeIcon,
|
||||
AccountCircle as AccountIcon,
|
||||
Image as ImageIcon,
|
||||
Upload as UploadIcon,
|
||||
ContentCopy as CopyIcon,
|
||||
Autorenew as ResetIcon,
|
||||
Logout as LogoutIcon,
|
||||
PeopleAlt as UsersIcon,
|
||||
Brush as BrushIcon
|
||||
} from '@material-ui/icons';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import Backdrop from './Backdrop';
|
||||
import { friendlyThemeName, themes } from './Theming';
|
||||
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 { useClipboard } from '@mantine/hooks';
|
||||
import { friendlyThemeName, themes } from './Theming';
|
||||
|
||||
function MenuItemLink(props) {
|
||||
return (
|
||||
<Link href={props.href} passHref>
|
||||
<MenuItem {...props} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem(props) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
sx={theme => ({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: 5,
|
||||
borderRadius: theme.radius.sm,
|
||||
color: props.color
|
||||
? theme.fn.themeColor(props.color, theme.colorScheme === 'dark' ? 5 : 7)
|
||||
: theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[0]
|
||||
: theme.black,
|
||||
'&:hover': {
|
||||
backgroundColor: props.color
|
||||
? theme.fn.rgba(
|
||||
theme.fn.themeColor(props.color, theme.colorScheme === 'dark' ? 9 : 0),
|
||||
theme.colorScheme === 'dark' ? 0.2 : 1
|
||||
)
|
||||
: theme.colorScheme === 'dark'
|
||||
? theme.fn.rgba(theme.colors.dark[3], 0.35)
|
||||
: theme.colors.gray[0],
|
||||
},
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
<Group noWrap>
|
||||
<Box sx={theme => ({
|
||||
marginRight: theme.spacing.xs / 4,
|
||||
paddingLeft: theme.spacing.xs / 2,
|
||||
|
||||
'& *': {
|
||||
display: 'block',
|
||||
},
|
||||
})}>
|
||||
{props.icon}
|
||||
</Box>
|
||||
<Text size='sm'>{props.children}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
const items = [
|
||||
{
|
||||
icon: <HomeIcon />,
|
||||
text: 'Home',
|
||||
link: '/dashboard'
|
||||
link: '/dashboard',
|
||||
},
|
||||
{
|
||||
icon: <ImageIcon />,
|
||||
text: 'Images',
|
||||
link: '/dashboard/images'
|
||||
icon: <FileIcon />,
|
||||
text: 'Files',
|
||||
link: '/dashboard/files',
|
||||
},
|
||||
{
|
||||
icon: <MixerHorizontalIcon />,
|
||||
text: 'Stats',
|
||||
link: '/dashboard/stats',
|
||||
},
|
||||
{
|
||||
icon: <Link1Icon />,
|
||||
text: 'URLs',
|
||||
link: '/dashboard/urls',
|
||||
},
|
||||
{
|
||||
icon: <UploadIcon />,
|
||||
text: 'Upload',
|
||||
link: '/dashboard/upload'
|
||||
}
|
||||
link: '/dashboard/upload',
|
||||
},
|
||||
];
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
function CopyTokenDialog({ open, setOpen, token }) {
|
||||
const handleCopyToken = () => {
|
||||
copy(token);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<DialogTitle id='copy-dialog-title'>
|
||||
Copy Token
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id='copy-dialog-description'>
|
||||
Make sure you don't share this token with anyone as they will be able to upload images on your behalf.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
|
||||
<Button onClick={handleCopyToken} color='inherit'>
|
||||
Copy
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetTokenDialog({ open, setOpen, setToken }) {
|
||||
const handleResetToken = async () => {
|
||||
const a = await useFetch('/api/user/token', 'PATCH');
|
||||
if (a.success) setToken(a.success);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<DialogTitle id='reset-dialog-title'>
|
||||
Reset Token
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id='reset-dialog-description'>
|
||||
Once you reset your token, you will have to update any uploaders to use this new token.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
|
||||
<Button onClick={handleResetToken} color='inherit'>
|
||||
Reset
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout({ children, user, loading, noPaper }) {
|
||||
const [systemTheme, setSystemTheme] = useState(user.systemTheme || 'dark_blue');
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [copyOpen, setCopyOpen] = useState(false);
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
export default function Layout({ children, user }) {
|
||||
const [token, setToken] = useState(user?.token);
|
||||
const [systemTheme, setSystemTheme] = useState(user.systemTheme ?? 'system');
|
||||
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 open = Boolean(anchorEl);
|
||||
const handleClick = e => setAnchorEl(e.currentTarget);
|
||||
const handleClose = (cmd: 'copy' | 'reset') => () => {
|
||||
switch (cmd) {
|
||||
case 'copy':
|
||||
setCopyOpen(true);
|
||||
break;
|
||||
case 'reset':
|
||||
setResetOpen(true);
|
||||
break;
|
||||
}
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleUpdateTheme = async (event: React.ChangeEvent<{ value: string }>) => {
|
||||
const handleUpdateTheme = async value => {
|
||||
const newUser = await useFetch('/api/user', 'PATCH', {
|
||||
systemTheme: event.target.value || 'dark_blue'
|
||||
systemTheme: value || 'dark_blue',
|
||||
});
|
||||
|
||||
setSystemTheme(newUser.systemTheme);
|
||||
dispatch(updateUser(newUser));
|
||||
|
||||
router.replace(router.pathname);
|
||||
|
||||
notif.showNotification({
|
||||
title: `Theme changed to ${friendlyThemeName[value]}`,
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <Pencil1Icon />,
|
||||
});
|
||||
};
|
||||
|
||||
const drawer = (
|
||||
<div>
|
||||
<CopyTokenDialog open={copyOpen} setOpen={setCopyOpen} token={token} />
|
||||
<ResetTokenDialog open={resetOpen} setOpen={setResetOpen} setToken={setToken} />
|
||||
<Toolbar
|
||||
sx={{
|
||||
width: { xs: drawerWidth }
|
||||
}}
|
||||
>
|
||||
<AppBar
|
||||
position='fixed'
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderBottomColor: t => t.palette.divider,
|
||||
display: { xs: 'none', sm: 'block' }
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color='inherit'
|
||||
aria-label='open drawer'
|
||||
edge='start'
|
||||
onClick={() => setMobileOpen(true)}
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography
|
||||
variant='h5'
|
||||
noWrap
|
||||
component='div'
|
||||
>
|
||||
Zipline
|
||||
</Typography>
|
||||
{user && (
|
||||
<Box sx={{ marginLeft: 'auto' }}>
|
||||
<Button
|
||||
color='inherit'
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<AccountIcon />
|
||||
</Button>
|
||||
<Menu
|
||||
id='zipline-user-menu'
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose(null)}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
<MenuItem disableRipple>
|
||||
<Typography variant='h5'>
|
||||
<b>{user.username}</b>
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<Link href='/dashboard/manage'>
|
||||
<a style={{ color: 'white', textDecoration: 'none' }}>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<AccountIcon sx={{ mr: 2 }} /> Manage Account
|
||||
</MenuItem>
|
||||
</a>
|
||||
</Link>
|
||||
<MenuItem onClick={handleClose('copy')}>
|
||||
<CopyIcon sx={{ mr: 2 }} /> Copy Token
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleClose('reset')}>
|
||||
<ResetIcon sx={{ mr: 2 }} /> Reset Token
|
||||
</MenuItem>
|
||||
<Link href='/auth/logout'>
|
||||
<a style={{ color: 'white', textDecoration: 'none' }}>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<LogoutIcon sx={{ mr: 2 }} /> Logout
|
||||
</MenuItem>
|
||||
</a>
|
||||
</Link>
|
||||
<MenuItem>
|
||||
<BrushIcon sx={{ mr: 2 }} />
|
||||
<Select
|
||||
variant='standard'
|
||||
label='Theme'
|
||||
value={systemTheme}
|
||||
onChange={handleUpdateTheme}
|
||||
fullWidth
|
||||
>
|
||||
{Object.keys(themes).map(t => (
|
||||
<MenuItem value={t} key={t}>
|
||||
{friendlyThemeName[t]}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<List>
|
||||
{items.map((item, i) => (
|
||||
<Link key={i} href={item.link}>
|
||||
<a href={item.link} style={{ color: 'white', textDecoration: 'none' }}>
|
||||
<ListItem button>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItem>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
{user && user.administrator && (
|
||||
<Link href='/dashboard/users' passHref>
|
||||
<a style={{ color: 'white', textDecoration: 'none' }}>
|
||||
<ListItem button>
|
||||
<ListItemIcon><UsersIcon /></ListItemIcon>
|
||||
<ListItemText primary='Users' />
|
||||
</ListItem>
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</List>
|
||||
|
||||
</div>
|
||||
);
|
||||
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.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: 'Reset', cancel: 'Cancel' },
|
||||
onConfirm: async () => {
|
||||
const a = await useFetch('/api/user/token', 'PATCH');
|
||||
if (!a.success) {
|
||||
setToken(a.success);
|
||||
notif.showNotification({
|
||||
title: 'Token Reset Failed',
|
||||
message: a.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'Token Reset',
|
||||
message: 'Your token has been reset. You will need to update any uploaders to use this new token.',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
const container = typeof window !== 'undefined' ? window.document.body : undefined;
|
||||
modals.closeAll();
|
||||
},
|
||||
});
|
||||
|
||||
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.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: 'Copy', cancel: 'Cancel' },
|
||||
onConfirm: async () => {
|
||||
clipboard.copy(token);
|
||||
|
||||
notif.showNotification({
|
||||
title: 'Token Copied',
|
||||
message: 'Your token has been copied to your clipboard.',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
});
|
||||
|
||||
modals.closeAll();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<Backdrop open={loading} />
|
||||
<AppShell
|
||||
navbarOffsetBreakpoint='sm'
|
||||
fixed
|
||||
navbar={
|
||||
<Navbar
|
||||
p='md'
|
||||
hiddenBreakpoint='sm'
|
||||
hidden={!opened}
|
||||
width={{ sm: 200, lg: 230 }}
|
||||
>
|
||||
<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>
|
||||
|
||||
<AppBar
|
||||
position='fixed'
|
||||
elevation={0}
|
||||
sx={{
|
||||
width: { sm: `calc(100% - ${drawerWidth}px)` },
|
||||
ml: { sm: `${drawerWidth}px` }
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color='inherit'
|
||||
aria-label='open drawer'
|
||||
edge='start'
|
||||
onClick={() => setMobileOpen(true)}
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography
|
||||
variant='h5'
|
||||
noWrap
|
||||
component='div'
|
||||
sx={{ display: { sm: 'none' } }}
|
||||
>
|
||||
Zipline
|
||||
</Typography>
|
||||
{user && (
|
||||
<Box sx={{ marginLeft: 'auto' }}>
|
||||
<Button
|
||||
color='inherit'
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
<Text size='lg'>{text}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</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>
|
||||
)}
|
||||
</Navbar.Section>
|
||||
</Navbar>
|
||||
}
|
||||
header={
|
||||
<Header height={70} p='md'>
|
||||
<div style={{ display: 'flex', alignItems: 'center', height: '100%' }}>
|
||||
<MediaQuery largerThan='sm' styles={{ display: 'none' }}>
|
||||
<Burger
|
||||
opened={opened}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
size='sm'
|
||||
color={theme.colors.gray[6]}
|
||||
/>
|
||||
</MediaQuery>
|
||||
<Title sx={{ marginLeft: 12 }}>Zipline</Title>
|
||||
<Box sx={{ marginLeft: 'auto', marginRight: 0 }}>
|
||||
<Popover
|
||||
position='top'
|
||||
placement='end'
|
||||
spacing={4}
|
||||
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>
|
||||
}
|
||||
>
|
||||
<AccountIcon />
|
||||
</Button>
|
||||
<Menu
|
||||
id='zipline-user-menu'
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose(null)}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
<MenuItem disableRipple>
|
||||
<Typography variant='h5'>
|
||||
<b>{user.username}</b>
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<Link href='/dash/manage'>
|
||||
<a style={{ color: 'white', textDecoration: 'none' }}>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<AccountIcon sx={{ mr: 2 }} /> Manage Account
|
||||
</MenuItem>
|
||||
</a>
|
||||
</Link>
|
||||
<MenuItem onClick={handleClose('copy')}>
|
||||
<CopyIcon sx={{ mr: 2 }} /> Copy Token
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleClose('reset')}>
|
||||
<ResetIcon sx={{ mr: 2 }} /> Reset Token
|
||||
</MenuItem>
|
||||
<Link href='/auth/logout'>
|
||||
<a style={{ color: 'white', textDecoration: 'none' }}>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<LogoutIcon sx={{ mr: 2 }} /> Logout
|
||||
</MenuItem>
|
||||
</a>
|
||||
</Link>
|
||||
</Menu>
|
||||
<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`,
|
||||
})}
|
||||
/>
|
||||
<MenuItem icon={<Pencil1Icon />}>
|
||||
<Select
|
||||
size='xs'
|
||||
data={Object.keys(themes).map(t => ({ value: t, label: friendlyThemeName[t] }))}
|
||||
value={systemTheme}
|
||||
onChange={handleUpdateTheme}
|
||||
/>
|
||||
</MenuItem>
|
||||
</Group>
|
||||
</Popover>
|
||||
</Box>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Box
|
||||
component='nav'
|
||||
sx={{
|
||||
width: { sm: drawerWidth },
|
||||
flexShrink: { sm: 0 }
|
||||
}}
|
||||
>
|
||||
<Drawer
|
||||
container={container}
|
||||
variant='temporary'
|
||||
onClose={() => setMobileOpen(false)}
|
||||
open={mobileOpen}
|
||||
elevation={0}
|
||||
ModalProps={{
|
||||
keepMounted: true
|
||||
}}
|
||||
sx={{
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth }
|
||||
}}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
<Drawer
|
||||
variant='permanent'
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth }
|
||||
}}
|
||||
open
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Box>
|
||||
<Box component='main' sx={{ flexGrow: 1, p: 3, mt: 8 }}>
|
||||
{user && noPaper ? children : (
|
||||
<Paper elevation={0} sx={{ p: 2 }} variant='outlined'>
|
||||
{children}
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</div>
|
||||
</Header>
|
||||
}
|
||||
>
|
||||
<Paper withBorder p='md' shadow='xs'>{children}</Paper>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import React, { forwardRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useRouter } from 'next/router';
|
||||
import NextLink from 'next/link';
|
||||
import MuiLink from '@material-ui/core/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 } =
|
||||
@@ -50,10 +50,10 @@ const Link = forwardRef(function Link(props: any, ref) {
|
||||
|
||||
if (isExternal) {
|
||||
if (noLinkStyle) {
|
||||
return <a className={className} href={href} ref={ref} {...other} />;
|
||||
return <Text variant='link' component='a' className={className} href={href} ref={ref} {...other} />;
|
||||
}
|
||||
|
||||
return <MuiLink className={className} href={href} ref={ref} {...other} />;
|
||||
return <Text component='a' variant='link' href={href} ref={ref} {...other} />;
|
||||
}
|
||||
|
||||
if (noLinkStyle) {
|
||||
@@ -61,8 +61,9 @@ const Link = forwardRef(function Link(props: any, ref) {
|
||||
}
|
||||
|
||||
return (
|
||||
<MuiLink
|
||||
<Text
|
||||
component={NextLinkComposed}
|
||||
variant='link'
|
||||
linkAs={linkAs}
|
||||
className={className}
|
||||
ref={ref}
|
||||
|
||||
6
src/components/StatText.tsx
Normal file
6
src/components/StatText.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Text } from '@mantine/core';
|
||||
|
||||
export default function StatText({ children }) {
|
||||
return <Text color='gray' size='xl'>{children}</Text>;
|
||||
}
|
||||
@@ -1,65 +1,96 @@
|
||||
import React from 'react';
|
||||
import { ThemeProvider } from '@emotion/react';
|
||||
import { CssBaseline } from '@material-ui/core';
|
||||
import React, { 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 polar from 'lib/themes/polar';
|
||||
import dracula from 'lib/themes/dracula';
|
||||
import matcha_dark_azul from 'lib/themes/matcha_dark_azul';
|
||||
import qogir_dark from 'lib/themes/qogir_dark';
|
||||
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import createTheme from 'lib/themes';
|
||||
import { MantineProvider, MantineThemeOverride } from '@mantine/core';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import { NotificationsProvider } from '@mantine/notifications';
|
||||
import { useColorScheme } from '@mantine/hooks';
|
||||
|
||||
export const themes = {
|
||||
'dark_blue': dark_blue,
|
||||
'dark': dark,
|
||||
'ayu_dark': ayu_dark,
|
||||
'ayu_mirage': ayu_mirage,
|
||||
'ayu_light': ayu_light,
|
||||
'nord': nord,
|
||||
'polar': polar
|
||||
system: (colorScheme: 'dark' | 'light') => colorScheme === 'dark' ? dark_blue : light_blue,
|
||||
dark_blue,
|
||||
light_blue,
|
||||
dark,
|
||||
ayu_dark,
|
||||
ayu_mirage,
|
||||
ayu_light,
|
||||
nord,
|
||||
dracula,
|
||||
matcha_dark_azul,
|
||||
qogir_dark,
|
||||
};
|
||||
|
||||
export const friendlyThemeName = {
|
||||
'system': 'System Theme',
|
||||
'dark_blue': 'Dark Blue',
|
||||
'light_blue': 'Light Blue',
|
||||
'dark': 'Very Dark',
|
||||
'ayu_dark': 'Ayu Dark',
|
||||
'ayu_mirage': 'Ayu Mirage',
|
||||
'ayu_light': 'Ayu Light',
|
||||
'nord': 'Nord',
|
||||
'polar': 'Polar'
|
||||
'dracula': 'Dracula',
|
||||
'matcha_dark_azul': 'Matcha Dark Azul',
|
||||
'qogir_dark': 'Qogir Dark',
|
||||
};
|
||||
|
||||
export default function ZiplineTheming({ Component, pageProps }) {
|
||||
let t;
|
||||
|
||||
export default function ZiplineTheming({ Component, pageProps, ...props }) {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
if (!user) t = themes.dark_blue;
|
||||
else {
|
||||
if (user.customTheme) {
|
||||
t = createTheme({
|
||||
type: 'dark',
|
||||
primary: user.customTheme.primary,
|
||||
secondary: user.customTheme.secondary,
|
||||
error: user.customTheme.error,
|
||||
warning: user.customTheme.warning,
|
||||
info: user.customTheme.info,
|
||||
border: user.customTheme.border,
|
||||
background: {
|
||||
main: user.customTheme.mainBackground,
|
||||
paper: user.customTheme.paperBackground
|
||||
}
|
||||
});
|
||||
} else {
|
||||
t = themes[user.systemTheme] ?? themes.dark_blue;
|
||||
}
|
||||
}
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
let theme: MantineThemeOverride;
|
||||
|
||||
if (!user) theme = themes.system(colorScheme);
|
||||
else if (user.systemTheme === 'system') theme = themes.system(colorScheme);
|
||||
else theme = themes[user.systemTheme] ?? themes.system(colorScheme);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty('color-scheme', theme.colorScheme);
|
||||
}, [user, theme]);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={t}>
|
||||
<CssBaseline />
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
theme={theme}
|
||||
styles={{
|
||||
AppShell: t => ({
|
||||
root: {
|
||||
backgroundColor: t.other.AppShell_backgroundColor,
|
||||
},
|
||||
}),
|
||||
Popover: {
|
||||
inner: {
|
||||
width: 200,
|
||||
},
|
||||
},
|
||||
Accordion: {
|
||||
itemTitle: {
|
||||
border: 0,
|
||||
},
|
||||
itemOpened: {
|
||||
border: 0,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ModalsProvider>
|
||||
<NotificationsProvider>
|
||||
{props.children ? props.children : <Component {...pageProps} />}
|
||||
</NotificationsProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,16 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TablePagination,
|
||||
TableRow,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Typography,
|
||||
Grid
|
||||
} from '@material-ui/core';
|
||||
|
||||
import Link from 'components/Link';
|
||||
import Card from 'components/Card';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import ZiplineImage from 'components/Image';
|
||||
import ImagesTable from 'components/ImagesTable';
|
||||
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';
|
||||
|
||||
type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify';
|
||||
|
||||
@@ -36,84 +28,56 @@ export function bytesToRead(bytes: number) {
|
||||
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ id: 'file', label: 'Name', minWidth: 170, align: 'inherit' as Aligns },
|
||||
{ id: 'mimetype', label: 'Type', minWidth: 100, align: 'inherit' as Aligns },
|
||||
{
|
||||
id: 'created_at',
|
||||
label: 'Date',
|
||||
minWidth: 170,
|
||||
align: 'right' as Aligns,
|
||||
format: (value) => new Date(value).toLocaleString(),
|
||||
}
|
||||
];
|
||||
|
||||
function StatText({ children }) {
|
||||
return <Typography variant='h5' color='GrayText'>{children}</Typography>;
|
||||
}
|
||||
|
||||
function StatTable({ rows, columns }) {
|
||||
return (
|
||||
<TableContainer sx={{ pt: 1 }}>
|
||||
<Table sx={{ minWidth: 100 }} size='small'>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columns.map(col => (
|
||||
<TableCell key={col.name}>{col.name}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row, i) => (
|
||||
<TableRow
|
||||
key={row.username}
|
||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||
>
|
||||
{columns.map(col => (
|
||||
<TableCell key={col.id}>
|
||||
{col.format ? col.format(row[col.id]) : row[col.id]}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
|
||||
const [images, setImages] = useState([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [recent, setRecent] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [apiLoading, setApiLoading] = useState(true);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const clipboard = useClipboard();
|
||||
const notif = useNotifications();
|
||||
|
||||
const updateImages = async () => {
|
||||
setApiLoading(true);
|
||||
|
||||
const imgs = await useFetch('/api/user/images');
|
||||
const imgs = await useFetch('/api/user/files');
|
||||
const recent = await useFetch('/api/user/recent?filter=media');
|
||||
const stts = await useFetch('/api/stats');
|
||||
setImages(imgs);
|
||||
setStats(stts);console.log(stts);
|
||||
|
||||
setApiLoading(false);
|
||||
setImages(imgs.map(x => ({ ...x, created_at: new Date(x.created_at).toLocaleString() })));
|
||||
setStats(stts);
|
||||
setRecent(recent);
|
||||
};
|
||||
|
||||
const handleChangePage = (event, newPage) => {
|
||||
setPage(newPage);
|
||||
const deleteImage = async ({ original }) => {
|
||||
const res = await useFetch('/api/user/files', 'DELETE', { id: original.id });
|
||||
if (!res.error) {
|
||||
updateImages();
|
||||
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 />,
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(+event.target.value);
|
||||
setPage(0);
|
||||
const copyImage = async ({ original }) => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${original.url}`);
|
||||
notif.showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async image => {
|
||||
const res = await useFetch('/api/user/images', 'DELETE', { id: image.id });
|
||||
if (!res.error) updateImages();
|
||||
const viewImage = async ({ original }) => {
|
||||
window.open(`${window.location.protocol}//${window.location.host}${original.url}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -122,113 +86,89 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={apiLoading} />
|
||||
<Typography variant='h4'>Welcome back {user?.username}</Typography>
|
||||
<Typography color='GrayText' pb={2}>You have <b>{images.length}</b> images</Typography>
|
||||
<Title>Welcome back {user?.username}</Title>
|
||||
<Text color='gray' sx={{ paddingBottom: 4 }}>You have <b>{images.length ? images.length : '...'}</b> files</Text>
|
||||
|
||||
<Typography variant='h4'>Stats</Typography>
|
||||
{stats && (
|
||||
<Grid container spacing={4} py={2}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card name='Size' sx={{ height: '100%' }}>
|
||||
<StatText>{stats.size}</StatText>
|
||||
<Typography variant='h3'>Average Size</Typography>
|
||||
<StatText>{bytesToRead(stats.size_num / stats.count)}</StatText>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card name='Images' sx={{ height: '100%' }}>
|
||||
<StatText>{stats.count}</StatText>
|
||||
<Typography variant='h3'>Views</Typography>
|
||||
<StatText>{stats.views_count} ({isNaN(stats.views_count / stats.count) ? '0' : stats.views_count / stats.count})</StatText>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card name='Users' sx={{ height: '100%' }}>
|
||||
<StatText>{stats.count_users}</StatText>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
<Title>Recent Files</Title>
|
||||
<SimpleGrid
|
||||
cols={4}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{recent.length ? recent.map(image => (
|
||||
<ZiplineImage key={randomId()} image={image} updateImages={updateImages} />
|
||||
)) : [1,2,3,4].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Card name='Images' sx={{ my: 2 }} elevation={0} variant='outlined'>
|
||||
<Link href='/dashboard/images' pb={2}>View Gallery</Link>
|
||||
<TableContainer sx={{ maxHeight: 440 }}>
|
||||
<Table size='small'>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableCell
|
||||
key={column.id}
|
||||
align={column.align}
|
||||
sx={{ minWidth: column.minWidth }}
|
||||
>
|
||||
{column.label}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell sx={{ minWidth: 200 }} align='right'>
|
||||
Actions
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{images
|
||||
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
|
||||
.map((row) => {
|
||||
return (
|
||||
<TableRow hover role='checkbox' tabIndex={-1} key={row.id}>
|
||||
{columns.map((column) => {
|
||||
const value = row[column.id];
|
||||
return (
|
||||
<TableCell key={column.id} align={column.align}>
|
||||
{column.format ? column.format(value) : value}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
<TableCell align='right'>
|
||||
<ButtonGroup variant='outlined'>
|
||||
<Button onClick={() => handleDelete(row)} color='error' size='small'>Delete</Button>
|
||||
</ButtonGroup>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
rowsPerPageOptions={[10, 25, 100]}
|
||||
component='div'
|
||||
count={images.length}
|
||||
rowsPerPage={rowsPerPage}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
/>
|
||||
<Title mt='md'>Stats</Title>
|
||||
<Text>View more stats here <Link href='/dashboard/stats'>here</Link>.</Text>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
<Card name='Size' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.size : <Skeleton height={8} />}</StatText>
|
||||
<Title order={2}>Average Size</Title>
|
||||
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
<Card name='Images' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.count : <Skeleton height={8} />}</StatText>
|
||||
<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>
|
||||
</Card>
|
||||
<Card name='Users' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.count_users : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<ImagesTable
|
||||
columns={[
|
||||
{ accessor: 'file', Header: 'Name', minWidth: 170, align: 'inherit' as Aligns },
|
||||
{ accessor: 'mimetype', Header: 'Type', minWidth: 100, align: 'inherit' as Aligns },
|
||||
{ accessor: 'created_at', Header: 'Date' },
|
||||
]}
|
||||
data={images}
|
||||
deleteImage={deleteImage}
|
||||
copyImage={copyImage}
|
||||
viewImage={viewImage}
|
||||
/>
|
||||
|
||||
{/* <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>
|
||||
{stats && (
|
||||
<>
|
||||
<Card name='Images per User' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'username', name: 'Name' },
|
||||
{ id: 'count', name: 'Images' }
|
||||
]}
|
||||
rows={stats.count_by_user}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card name='Types' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'mimetype', name: 'Type' },
|
||||
{ id: 'count', name: 'Count' }
|
||||
]}
|
||||
rows={stats.types_count}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
<Card name='Types' mt={22}>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'mimetype', name: 'Type' },
|
||||
{ id: 'count', name: 'Count' },
|
||||
]}
|
||||
rows={stats ? stats.types_count : []} />
|
||||
</Card> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
104
src/components/pages/Files.tsx
Normal file
104
src/components/pages/Files.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import ZiplineImage from 'components/Image';
|
||||
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';
|
||||
|
||||
export default function Files() {
|
||||
const [pages, setPages] = useState([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [favoritePages, setFavoritePages] = useState([]);
|
||||
const [favoritePage, setFavoritePage] = useState(1);
|
||||
|
||||
const updatePages = async favorite => {
|
||||
const pages = await useFetch('/api/user/files?paged=true&filter=media');
|
||||
if (favorite) {
|
||||
const fPages = await useFetch('/api/user/files?paged=true&favorite=media');
|
||||
setFavoritePages(fPages);
|
||||
}
|
||||
setPages(pages);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updatePages(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group>
|
||||
<Title sx={{ marginBottom: 12 }}>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>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{pages.length ? pages[(page - 1) ?? 0].map(image => (
|
||||
<div key={image.id}>
|
||||
<ZiplineImage image={image} updateImages={() => updatePages(true)} />
|
||||
</div>
|
||||
)) : [1,2,3,4].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{pages.length ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Pagination total={pages.length} page={page} onChange={setPage}/>
|
||||
</Box>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Grid, Pagination, Box, Typography } from '@material-ui/core';
|
||||
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import ZiplineImage from 'components/Image';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
|
||||
export default function Upload() {
|
||||
const [pages, setPages] = useState([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const updatePages = async () => {
|
||||
setLoading(true);
|
||||
const pages = await useFetch('/api/user/images?paged=true&filter=image');
|
||||
setPages(pages);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updatePages();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={loading}/>
|
||||
{!pages.length && (
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
pt={2}
|
||||
pb={3}
|
||||
>
|
||||
<Typography variant='h4'>No Images</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Grid container spacing={2}>
|
||||
{pages.length ? pages[(page - 1) ?? 0].map(image => (
|
||||
<Grid item xs={12} sm={3} key={image.id}>
|
||||
<ZiplineImage image={image} updateImages={updatePages} />
|
||||
</Grid>
|
||||
)) : null}
|
||||
</Grid>
|
||||
|
||||
{pages.length ? (
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
pt={2}
|
||||
>
|
||||
<Pagination count={pages.length} page={page} onChange={(_, v) => setPage(v)}/>
|
||||
</Box>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,223 +1,294 @@
|
||||
import React, { useState } from 'react';
|
||||
import { TextField, Button, Box, Typography, Select, MenuItem } from '@material-ui/core';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { useFormik } from 'formik';
|
||||
import * as yup from 'yup';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import Alert from 'components/Alert';
|
||||
import Link from 'components/Link';
|
||||
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
import { useRouter } from 'next/router';
|
||||
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 { useModals } from '@mantine/modals';
|
||||
|
||||
const validationSchema = yup.object({
|
||||
username: yup
|
||||
.string()
|
||||
.required('Username is required')
|
||||
});
|
||||
|
||||
const themeValidationSchema = yup.object({
|
||||
type: yup
|
||||
.string()
|
||||
.required('Type (dark, light) is required is required'),
|
||||
primary: yup
|
||||
.string()
|
||||
.required('Primary color is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
secondary: yup
|
||||
.string()
|
||||
.required('Secondary color is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
error: yup
|
||||
.string()
|
||||
.required('Error color is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
warning: yup
|
||||
.string()
|
||||
.required('Warning color is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
info: yup
|
||||
.string()
|
||||
.required('Info color is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
border: yup
|
||||
.string()
|
||||
.required('Border color is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
mainBackground: yup
|
||||
.string()
|
||||
.required('Main Background is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
paperBackground: yup
|
||||
.string()
|
||||
.required('Paper Background is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
|
||||
});
|
||||
|
||||
function TextInput({ id, label, formik, ...other }) {
|
||||
function VarsTooltip({ children }) {
|
||||
return (
|
||||
<TextField
|
||||
id={id}
|
||||
name={id}
|
||||
label={label}
|
||||
value={formik.values[id]}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched[id] && Boolean(formik.errors[id])}
|
||||
helperText={formik.touched[id] && formik.errors[id]}
|
||||
variant='standard'
|
||||
fullWidth
|
||||
sx={{ pb: 0.5 }}
|
||||
{...other}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Manage() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
const dispatch = useStoreDispatch();
|
||||
const router = useRouter();
|
||||
const notif = useNotifications();
|
||||
const modals = useModals();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [severity, setSeverity] = useState('success');
|
||||
const [message, setMessage] = useState('Saved');
|
||||
const [exports, setExports] = useState([]);
|
||||
const [domains, setDomains] = useState(user.domains ?? []);
|
||||
|
||||
const formik = useFormik({
|
||||
const genShareX = (withEmbed: boolean = false, withZws: boolean = false) => {
|
||||
const config = {
|
||||
Version: '13.2.1',
|
||||
Name: 'Zipline',
|
||||
DestinationType: 'ImageUploader, TextUploader',
|
||||
RequestMethod: 'POST',
|
||||
RequestURL: `${window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : '')}/api/upload`,
|
||||
Headers: {
|
||||
Authorization: user?.token,
|
||||
...(withEmbed && { Embed: 'true' }),
|
||||
...(withZws && { ZWS: 'true' }),
|
||||
},
|
||||
URL: '$json:files[0]$',
|
||||
Body: 'MultipartFormData',
|
||||
FileFormName: 'file',
|
||||
};
|
||||
|
||||
const pseudoElement = document.createElement('a');
|
||||
pseudoElement.setAttribute('href', 'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(config, null, '\t')));
|
||||
pseudoElement.setAttribute('download', `zipline${withEmbed ? '_embed' : ''}${withZws ? '_zws' : ''}.sxcu`);
|
||||
pseudoElement.style.display = 'none';
|
||||
document.body.appendChild(pseudoElement);
|
||||
pseudoElement.click();
|
||||
pseudoElement.parentNode.removeChild(pseudoElement);
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: user.username,
|
||||
password: '',
|
||||
embedTitle: user.embedTitle ?? '',
|
||||
embedColor: user.embedColor
|
||||
embedColor: user.embedColor,
|
||||
embedSiteName: user.embedSiteName ?? '',
|
||||
domains: user.domains ?? [],
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: async values => {
|
||||
const cleanUsername = values.username.trim();
|
||||
const cleanPassword = values.password.trim();
|
||||
const cleanEmbedTitle = values.embedTitle.trim();
|
||||
const cleanEmbedColor = values.embedColor.trim();
|
||||
|
||||
if (cleanUsername === '') return formik.setFieldError('username', 'Username can\'t be nothing');
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const data = {
|
||||
username: cleanUsername,
|
||||
password: cleanPassword === '' ? null : cleanPassword,
|
||||
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
|
||||
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor
|
||||
};
|
||||
|
||||
const newUser = await useFetch('/api/user', 'PATCH', data);
|
||||
|
||||
if (newUser.error) {
|
||||
setLoading(false);
|
||||
setMessage('An error occured');
|
||||
setSeverity('error');
|
||||
setOpen(true);
|
||||
} else {
|
||||
dispatch(updateUser(newUser));
|
||||
setLoading(false);
|
||||
setMessage('Saved user');
|
||||
setSeverity('success');
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const customThemeFormik = useFormik({
|
||||
initialValues: {
|
||||
type: user.customTheme?.type || 'dark',
|
||||
primary: user.customTheme?.primary || '',
|
||||
secondary: user.customTheme?.secondary || '',
|
||||
error: user.customTheme?.error || '',
|
||||
warning: user.customTheme?.warning || '',
|
||||
info: user.customTheme?.info || '',
|
||||
border: user.customTheme?.border || '',
|
||||
mainBackground: user.customTheme?.mainBackground || '',
|
||||
paperBackground: user.customTheme?.paperBackground || '',
|
||||
},
|
||||
validationSchema: themeValidationSchema,
|
||||
onSubmit: async values => {
|
||||
setLoading(true);
|
||||
const onSubmit = async values => {
|
||||
const cleanUsername = values.username.trim();
|
||||
const cleanPassword = values.password.trim();
|
||||
const cleanEmbedTitle = values.embedTitle.trim();
|
||||
const cleanEmbedColor = values.embedColor.trim();
|
||||
const cleanEmbedSiteName = values.embedSiteName.trim();
|
||||
|
||||
const newUser = await useFetch('/api/user', 'PATCH', { customTheme: values });
|
||||
console.log(newUser);
|
||||
if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
|
||||
|
||||
if (newUser.error) {
|
||||
setLoading(false);
|
||||
setMessage('An error occured');
|
||||
setSeverity('error');
|
||||
setOpen(true);
|
||||
} else {
|
||||
dispatch(updateUser(newUser));
|
||||
router.replace(router.pathname);
|
||||
setLoading(false);
|
||||
setMessage('Saved theme');
|
||||
setSeverity('success');
|
||||
setOpen(true);
|
||||
const id = notif.showNotification({
|
||||
title: 'Updating user...',
|
||||
message: '',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
const data = {
|
||||
username: cleanUsername,
|
||||
password: cleanPassword === '' ? null : cleanPassword,
|
||||
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
|
||||
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor,
|
||||
embedSiteName: cleanEmbedSiteName === '' ? null : cleanEmbedSiteName,
|
||||
domains,
|
||||
};
|
||||
|
||||
const newUser = await useFetch('/api/user', 'PATCH', data);
|
||||
|
||||
if (newUser.error) {
|
||||
if (newUser.invalidDomains) {
|
||||
notif.updateNotification(id, {
|
||||
message: <>
|
||||
<Text mt='xs'>The following domains are invalid:</Text>
|
||||
{newUser.invalidDomains.map(err => (
|
||||
<>
|
||||
<Text color='gray' key={randomId()}>{err.domain}: {err.reason}</Text>
|
||||
<Space h='md' />
|
||||
</>
|
||||
))}
|
||||
</>,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
}
|
||||
notif.updateNotification(id, {
|
||||
title: 'Couldn\'t save user',
|
||||
message: newUser.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
} else {
|
||||
dispatch(updateUser(newUser));
|
||||
notif.updateNotification(id, {
|
||||
title: 'Saved User',
|
||||
message: '',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const exportData = async () => {
|
||||
const res = await useFetch('/api/user/export', 'POST');
|
||||
if (res.url) {
|
||||
notif.showNotification({
|
||||
title: 'Export started...',
|
||||
loading: true,
|
||||
message: 'If you have a lot of files, the export may take a while. The list of exports will be updated every 30s.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getExports = async () => {
|
||||
const res = await useFetch('/api/user/export');
|
||||
|
||||
setExports(res.exports.map(s => ({
|
||||
date: new Date(Number(s.split('_')[3].slice(0, -4))),
|
||||
full: s,
|
||||
})).sort((a, b) => a.date.getTime() - b.date.getTime()));
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await useFetch('/api/user/files', 'DELETE', {
|
||||
all: true,
|
||||
});
|
||||
|
||||
if (!res.count) {
|
||||
notif.showNotification({
|
||||
title: 'Couldn\'t delete files',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'Deleted files',
|
||||
message: `${res.count} files deleted`,
|
||||
color: 'green',
|
||||
icon: <TrashIcon />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
modals.closeAll();
|
||||
},
|
||||
onCancel: () => {
|
||||
modals.closeAll();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const interval = useInterval(() => getExports(), 30000);
|
||||
useEffect(() => {
|
||||
getExports();
|
||||
interval.start();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={loading}/>
|
||||
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
|
||||
<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>
|
||||
<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')} />
|
||||
<TextInput id='embedTitle' label='Embed Title' {...form.getInputProps('embedTitle')} />
|
||||
<ColorInput id='embedColor' label='Embed Color' {...form.getInputProps('embedColor')} />
|
||||
<TextInput id='embedSiteName' label='Embed Site Name' {...form.getInputProps('embedSiteName')} />
|
||||
<MultiSelect
|
||||
id='domains'
|
||||
label='Domains'
|
||||
data={domains}
|
||||
placeholder='Leave blank if you dont want random domain selection.'
|
||||
creatable
|
||||
searchable
|
||||
clearable
|
||||
getCreateLabel={query => `Add ${query}`}
|
||||
onCreate={query => setDomains((current) => [...current, query])}
|
||||
{...form.getInputProps('domains')}
|
||||
/>
|
||||
|
||||
<Typography variant='h4' pb={2}>Manage User</Typography>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput id='username' label='Username' formik={formik} />
|
||||
<TextInput id='password' label='Password' formik={formik} type='password' />
|
||||
<TextInput id='embedTitle' label='Embed Title' formik={formik} />
|
||||
<TextInput id='embedColor' label='Embed Color' formik={formik} />
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='right'
|
||||
alignItems='right'
|
||||
pt={2}
|
||||
>
|
||||
<Group position='right' sx={{ paddingTop: 12 }}>
|
||||
<Button
|
||||
variant='contained'
|
||||
type='submit'
|
||||
>Save User</Button>
|
||||
</Box>
|
||||
</form>
|
||||
<Typography variant='h4' py={2}>Manage Theme</Typography>
|
||||
<form onSubmit={customThemeFormik.handleSubmit}>
|
||||
<Select
|
||||
id='type'
|
||||
name='type'
|
||||
label='Type'
|
||||
value={customThemeFormik.values['type']}
|
||||
onChange={customThemeFormik.handleChange}
|
||||
error={customThemeFormik.touched['type'] && Boolean(customThemeFormik.errors['type'])}
|
||||
variant='standard'
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value='dark'>Dark Theme</MenuItem>
|
||||
<MenuItem value='light'>Light Theme</MenuItem>
|
||||
</Select>
|
||||
<TextInput id='primary' label='Primary Color' formik={customThemeFormik} />
|
||||
<TextInput id='secondary' label='Secondary Color' formik={customThemeFormik} />
|
||||
<TextInput id='error' label='Error Color' formik={customThemeFormik} />
|
||||
<TextInput id='warning' label='Warning Color' formik={customThemeFormik} />
|
||||
<TextInput id='info' label='Info Color' formik={customThemeFormik} />
|
||||
<TextInput id='border' label='Border Color' formik={customThemeFormik} />
|
||||
<TextInput id='mainBackground' label='Main Background' formik={customThemeFormik} />
|
||||
<TextInput id='paperBackground' label='Paper Background' formik={customThemeFormik} />
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='right'
|
||||
alignItems='right'
|
||||
pt={2}
|
||||
>
|
||||
<Button
|
||||
variant='contained'
|
||||
type='submit'
|
||||
>Save Theme</Button>
|
||||
</Box>
|
||||
</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>
|
||||
<Group>
|
||||
<Button onClick={openDeleteModal} rightIcon={<TrashIcon />}>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(),
|
||||
})) : []} />
|
||||
</Card>
|
||||
|
||||
<Title sx={{ paddingTop: 12, paddingBottom: 12 }}>ShareX Config</Title>
|
||||
<Group>
|
||||
<Button onClick={() => genShareX(false)} rightIcon={<DownloadIcon />}>ShareX Config</Button>
|
||||
<Button onClick={() => genShareX(true)} rightIcon={<DownloadIcon />}>ShareX Config with Embed</Button>
|
||||
<Button onClick={() => genShareX(false, true)} rightIcon={<DownloadIcon />}>ShareX Config with ZWS</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
106
src/components/pages/Stats.tsx
Normal file
106
src/components/pages/Stats.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import Card from 'components/Card';
|
||||
import StatText from 'components/StatText';
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Stats() {
|
||||
const [stats, setStats] = useState(null);
|
||||
|
||||
const update = async () => {
|
||||
const stts = await useFetch('/api/stats');
|
||||
setStats(stts);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
update();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Stats</Title>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
<Card name='Size' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.size : <Skeleton height={8} />}</StatText>
|
||||
<Title order={2}>Average Size</Title>
|
||||
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
<Card name='Images' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.count : <Skeleton height={8} />}</StatText>
|
||||
<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>
|
||||
</Card>
|
||||
<Card name='Users' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.count_users : <Skeleton height={8} />}</StatText>
|
||||
</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>
|
||||
<Card name='Types' mt={22}>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'mimetype', name: 'Type' },
|
||||
{ id: 'count', name: 'Count' },
|
||||
]}
|
||||
rows={stats ? stats.types_count : []} />
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +1,176 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Typography, Button, CardActionArea, Paper, Box } from '@material-ui/core';
|
||||
import { Upload as UploadIcon } from '@material-ui/icons';
|
||||
import Dropzone from 'react-dropzone';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import Alert from 'components/Alert';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import CenteredBox from 'components/CenteredBox';
|
||||
import copy from 'copy-to-clipboard';
|
||||
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 { randomId, useClipboard } from '@mantine/hooks';
|
||||
|
||||
export default function Upload({ route }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Upload() {
|
||||
const theme = useMantineTheme();
|
||||
const notif = useNotifications();
|
||||
const clipboard = useClipboard();
|
||||
const user = useStoreSelector(state => state.user);
|
||||
|
||||
const [file, setFile] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [severity, setSeverity] = useState('success');
|
||||
const [message, setMessage] = useState('Saved');
|
||||
const [files, setFiles] = useState([]);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
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({
|
||||
title: 'Image imported from clipboard',
|
||||
message: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const handleUpload = async () => {
|
||||
setProgress(0);
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
for (let i = 0; i !== files.length; ++i) body.append('file', files[i]);
|
||||
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': user.token
|
||||
},
|
||||
body
|
||||
const id = notif.showNotification({
|
||||
title: 'Uploading Images...',
|
||||
message: '',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (res.ok && json.error === undefined) {
|
||||
setOpen(true);
|
||||
setSeverity('success');
|
||||
setMessage(`Copied to clipboard! ${json.url}`);
|
||||
copy(json.url);
|
||||
} else {
|
||||
setOpen(true);
|
||||
setSeverity('error');
|
||||
setMessage('Could not upload file: ' + json.error);
|
||||
}
|
||||
setLoading(false);
|
||||
|
||||
const req = new XMLHttpRequest();
|
||||
req.upload.addEventListener('progress', e => {
|
||||
if (e.lengthComputable) {
|
||||
setProgress(Math.round(e.loaded / e.total * 100));
|
||||
}
|
||||
});
|
||||
|
||||
req.addEventListener('load', e => {
|
||||
// @ts-ignore not sure why it thinks response doesnt exist, but it does.
|
||||
const json = JSON.parse(e.target.response);
|
||||
|
||||
if (json.error === undefined) {
|
||||
notif.updateNotification(id, {
|
||||
title: 'Upload Successful',
|
||||
message: <>Copied first image to clipboard! <br />{json.files.map(x => (<Link key={x} href={x}>{x}<br /></Link>))}</>,
|
||||
color: 'green',
|
||||
icon: <UploadIcon />,
|
||||
});
|
||||
clipboard.copy(json.files[0]);
|
||||
setFiles([]);
|
||||
} else {
|
||||
notif.updateNotification(id, {
|
||||
title: 'Upload Failed',
|
||||
message: json.error,
|
||||
color: 'red',
|
||||
icon: <CrossCircledIcon />,
|
||||
});
|
||||
}
|
||||
setProgress(0);
|
||||
}, false);
|
||||
|
||||
req.open('POST', '/api/upload');
|
||||
req.setRequestHeader('Authorization', user.token);
|
||||
req.send(body);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={loading}/>
|
||||
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
|
||||
<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) }}
|
||||
/>
|
||||
|
||||
<Typography variant='h4' pb={2}>Upload file</Typography>
|
||||
<Dropzone onDrop={acceptedFiles => setFile(acceptedFiles[0])}>
|
||||
{({getRootProps, getInputProps}) => (
|
||||
<CardActionArea>
|
||||
<Paper
|
||||
elevation={0}
|
||||
variant='outlined'
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
display: 'block',
|
||||
p: 5
|
||||
}}
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<CenteredBox><UploadIcon sx={{ fontSize: 100 }} /></CenteredBox>
|
||||
<CenteredBox><Typography variant='h5'>Drag an image or click to upload an image.</Typography></CenteredBox>
|
||||
<CenteredBox><Typography variant='h6'>{file && file.name}</Typography></CenteredBox>
|
||||
</Paper>
|
||||
</CardActionArea>
|
||||
<div>
|
||||
<Text size='xl' inline>
|
||||
Drag images here or click to select files
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
|
||||
</>
|
||||
)}
|
||||
</Dropzone>
|
||||
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='right'
|
||||
alignItems='right'
|
||||
pt={2}
|
||||
>
|
||||
<Button
|
||||
variant='contained'
|
||||
onClick={handleUpload}
|
||||
>Upload</Button>
|
||||
</Box>
|
||||
<Group position='center' spacing='xl' mt={12}>
|
||||
{files.map(file => (<ImageDropzone key={randomId()} file={file} />))}
|
||||
</Group>
|
||||
|
||||
{progress !== 0 && <Progress sx={{ marginTop: 12 }} value={progress} />}
|
||||
|
||||
<Group position='right'>
|
||||
<Button leftIcon={<UploadIcon />} mt={12} onClick={handleUpload}>Upload</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
161
src/components/pages/Urls.tsx
Normal file
161
src/components/pages/Urls.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
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';
|
||||
|
||||
export default function Urls() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
const notif = useNotifications();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const [urls, setURLS] = useState([]);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const updateURLs = async () => {
|
||||
const urls = await useFetch('/api/user/urls');
|
||||
|
||||
setURLS(urls);
|
||||
};
|
||||
|
||||
const deleteURL = async u => {
|
||||
const url = await useFetch('/api/user/urls', 'DELETE', { id: u.id });
|
||||
if (url.error) {
|
||||
notif.showNotification({
|
||||
title: 'Failed to delete URL',
|
||||
message: url.error,
|
||||
icon: <TrashIcon />,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'Deleted URL',
|
||||
message: '',
|
||||
icon: <Cross1Icon />,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
|
||||
updateURLs();
|
||||
};
|
||||
|
||||
const copyURL = u => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
|
||||
notif.showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
url: '',
|
||||
vanity: '',
|
||||
},
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
const data = {
|
||||
url: cleanURL,
|
||||
vanity: cleanVanity === '' ? null : cleanVanity,
|
||||
};
|
||||
|
||||
setCreateOpen(false);
|
||||
const res = await fetch('/api/shorten', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': user.token,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
notif.showNotification({
|
||||
title: 'Failed to create URL',
|
||||
message: json.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'URL shortened',
|
||||
message: json.url,
|
||||
color: 'green',
|
||||
icon: <Link1Icon />,
|
||||
});
|
||||
}
|
||||
|
||||
updateURLs();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateURLs();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title={<Title>Shorten URL</Title>}
|
||||
>
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<TextInput id='url' label='URL' {...form.getInputProps('url')} />
|
||||
<TextInput id='vanity' label='Vanity' {...form.getInputProps('vanity')} />
|
||||
|
||||
<Group position='right' mt={22}>
|
||||
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button type='submit'>Submit</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Group>
|
||||
<Title sx={{ marginBottom: 12 }}>URLs</Title>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setCreateOpen(true)}><PlusIcon/></ActionIcon>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid
|
||||
cols={4}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{urls.length ? urls.map(url => (
|
||||
<Card key={url.id} sx={{ maxWidth: '100%' }} shadow='sm'>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Title>{url.vanity ?? url.id}</Title>
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
<ActionIcon href={url.url} component='a' target='_blank'><Link1Icon/></ActionIcon>
|
||||
<ActionIcon aria-label='copy' onClick={() => copyURL(url)}>
|
||||
<CopyIcon />
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='delete' onClick={() => deleteURL(url)}>
|
||||
<TrashIcon />
|
||||
</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>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,168 +1,141 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Card as MuiCard,
|
||||
CardHeader,
|
||||
Avatar,
|
||||
IconButton,
|
||||
Grid,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Switch,
|
||||
FormControlLabel
|
||||
} from '@material-ui/core';
|
||||
import { Delete as DeleteIcon, Add as AddIcon } from '@material-ui/icons';
|
||||
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import Alert from 'components/Alert';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useFormik } from 'formik';
|
||||
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 { useModals } from '@mantine/modals';
|
||||
|
||||
function Card({ user, handleDelete }) {
|
||||
return (
|
||||
<MuiCard sx={{ minWidth: 270 }}>
|
||||
<CardHeader
|
||||
avatar={<Avatar>{user.username[0]}</Avatar>}
|
||||
action={<IconButton onClick={() => handleDelete(user)}><DeleteIcon /></IconButton>}
|
||||
title={<Typography variant='h6'>{user.username}</Typography>}
|
||||
/>
|
||||
</MuiCard>
|
||||
);
|
||||
}
|
||||
|
||||
function TextInput({ id, label, formik, ...other }) {
|
||||
return (
|
||||
<TextField
|
||||
id={id}
|
||||
name={id}
|
||||
label={label}
|
||||
value={formik.values[id]}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched[id] && Boolean(formik.errors[id])}
|
||||
helperText={formik.touched[id] && formik.errors[id]}
|
||||
variant='standard'
|
||||
fullWidth
|
||||
sx={{ pb: 0.5 }}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateUserDialog({ open, setOpen, updateUsers, setSeverity, setMessage, setLoading, setAlertOpen }) {
|
||||
const formik = useFormik({
|
||||
function CreateUserModal({ open, setOpen, updateUsers }) {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
administrator: false
|
||||
administrator: false,
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
const cleanUsername = values.username.trim();
|
||||
const cleanPassword = values.password.trim();
|
||||
if (cleanUsername === '') return formik.setFieldError('username', 'Username can\'t be nothing');
|
||||
if (cleanPassword === '') return formik.setFieldError('password', 'Password can\'t be nothing');
|
||||
|
||||
const data = {
|
||||
username: cleanUsername,
|
||||
password: cleanPassword,
|
||||
administrator: values.administrator
|
||||
};
|
||||
|
||||
setOpen(false);
|
||||
setLoading(true);
|
||||
const res = await useFetch('/api/auth/create', 'POST', data);
|
||||
if (res.error) {
|
||||
setSeverity('error');
|
||||
setMessage('Could\'nt create user: ' + res.error);
|
||||
setAlertOpen(true);
|
||||
} else {
|
||||
setSeverity('success');
|
||||
setMessage('Created user ' + res.username);
|
||||
setAlertOpen(true);
|
||||
updateUsers();
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const notif = useNotifications();
|
||||
|
||||
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');
|
||||
if (cleanPassword === '') return form.setFieldError('password', 'Password can\'t be nothing');
|
||||
|
||||
const data = {
|
||||
username: cleanUsername,
|
||||
password: cleanPassword,
|
||||
administrator: values.administrator,
|
||||
};
|
||||
|
||||
setOpen(false);
|
||||
const res = await useFetch('/api/auth/create', 'POST', data);
|
||||
if (res.error) {
|
||||
notif.showNotification({
|
||||
title: 'Failed to create user',
|
||||
message: res.error,
|
||||
icon: <TrashIcon />,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'Created user: ' + cleanUsername,
|
||||
message: '',
|
||||
icon: <PlusIcon />,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
|
||||
updateUsers();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
PaperProps={{
|
||||
elevation: 1
|
||||
}}
|
||||
>
|
||||
<DialogTitle>
|
||||
Create User
|
||||
</DialogTitle>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<DialogContent>
|
||||
<TextInput id='username' label='Username' formik={formik} />
|
||||
<TextInput id='password' label='Password' formik={formik} type='password' />
|
||||
<FormControlLabel
|
||||
id='administrator'
|
||||
name='administrator'
|
||||
value={formik.values.administrator}
|
||||
onChange={formik.handleChange}
|
||||
control={<Switch />}
|
||||
label='Administrator?'
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
|
||||
<Button type='submit' color='inherit'>
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
</div>
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Title>Create User</Title>}
|
||||
>
|
||||
<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')} />
|
||||
|
||||
<Group position='right' mt={22}>
|
||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button type='submit'>Create</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Users() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
const router = useRouter();
|
||||
const notif = useNotifications();
|
||||
const modals = useModals();
|
||||
|
||||
const [users, setUsers] = useState([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [severity, setSeverity] = useState('success');
|
||||
const [message, setMessage] = useState('Saved');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const handleDelete = async (user, delete_images) => {
|
||||
const res = await useFetch('/api/users', 'DELETE', {
|
||||
id: user.id,
|
||||
delete_images,
|
||||
});
|
||||
if (res.error) {
|
||||
notif.showNotification({
|
||||
title: 'Failed to delete user',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'User deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <TrashIcon />,
|
||||
});
|
||||
updateUsers();
|
||||
}
|
||||
};
|
||||
|
||||
// 2-step modal for deleting user if they want to delete their images too.
|
||||
const openDeleteModal = user => modals.openConfirmModal({
|
||||
title: `Delete ${user.username}?`,
|
||||
closeOnConfirm: false,
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
labels: { confirm: 'Yes', cancel: 'No' },
|
||||
onConfirm: () => {
|
||||
modals.openConfirmModal({
|
||||
title: `Delete ${user.username}'s images?`,
|
||||
labels: { confirm: 'Yes', cancel: 'No' },
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
onConfirm: () => {
|
||||
handleDelete(user, true);
|
||||
modals.closeAll();
|
||||
},
|
||||
onCancel: () => {
|
||||
handleDelete(user, false);
|
||||
modals.closeAll();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateUsers = async () => {
|
||||
setLoading(true);
|
||||
const us = await useFetch('/api/users');
|
||||
if (!us.error) {
|
||||
setUsers(us);
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
};
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (user) => {
|
||||
const res = await useFetch('/api/users', 'DELETE', {
|
||||
id: user.id
|
||||
});
|
||||
if (res.error) {
|
||||
setMessage(`Could not delete ${user.username}`);
|
||||
setSeverity('error');
|
||||
setOpen(true);
|
||||
} else {
|
||||
setMessage(`Deleted user ${res.username}`);
|
||||
setSeverity('success');
|
||||
setOpen(true);
|
||||
updateUsers();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -171,17 +144,38 @@ export default function Users() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={loading}/>
|
||||
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
|
||||
<CreateUserDialog open={createOpen} setOpen={setCreateOpen} setSeverity={setSeverity} setMessage={setMessage} setLoading={setLoading} updateUsers={updateUsers} setAlertOpen={setOpen} />
|
||||
<Typography variant='h4' pb={2}>Users <IconButton onClick={() => setCreateOpen(true)}><AddIcon /></IconButton></Typography>
|
||||
<Grid container spacing={2}>
|
||||
{users.filter(x => x.username !== user.username).map((user, i) => (
|
||||
<Grid item xs={12} sm={3} key={i}>
|
||||
<Card user={user} handleDelete={handleDelete}/>
|
||||
</Grid>
|
||||
<CreateUserModal open={open} setOpen={setOpen} updateUsers={updateUsers} />
|
||||
<Group>
|
||||
<Title sx={{ marginBottom: 12 }}>Users</Title>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon /></ActionIcon>
|
||||
</Group>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{users.length ? users.filter(x => x.username !== user.username).map(user => (
|
||||
<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>
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(user)}>
|
||||
<TrashIcon />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)) : [1, 2, 3, 4].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
|
||||
</div>
|
||||
))}
|
||||
</Grid>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
src/lib/clientUtils.ts
Normal file
16
src/lib/clientUtils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Image, User } from '@prisma/client';
|
||||
|
||||
export function parse(str: string, image: Image, user: User) {
|
||||
if (!str) return null;
|
||||
|
||||
return str
|
||||
.replace(/{user.admin}/gi, user.administrator ? 'yes' : 'no')
|
||||
.replace(/{user.id}/gi, user.id.toString())
|
||||
.replace(/{user.name}/gi, user.username)
|
||||
.replace(/{image.id}/gi, image.id.toString())
|
||||
.replace(/{image.mime}/gi, image.mimetype)
|
||||
.replace(/{image.file}/gi, image.file)
|
||||
.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());
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Config } from './types';
|
||||
import readConfig from './readConfig';
|
||||
import readConfig from './config/readConfig';
|
||||
import validateConfig from './config/validateConfig';
|
||||
|
||||
if (!global.config) global.config = readConfig() as Config;
|
||||
if (!global.config) global.config = validateConfig(readConfig());
|
||||
|
||||
export default global.config;
|
||||
98
src/lib/config/Config.ts
Normal file
98
src/lib/config/Config.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
export interface ConfigCore {
|
||||
// Whether to return http or https links
|
||||
secure: boolean;
|
||||
|
||||
// Used for signing of cookies and other stuff
|
||||
secret: string;
|
||||
|
||||
// The host Zipline will run on
|
||||
host: string;
|
||||
|
||||
// The port Zipline will run on
|
||||
port: number;
|
||||
|
||||
// The PostgreSQL database url
|
||||
database_url: string
|
||||
|
||||
// Whether or not to log stuff
|
||||
logger: boolean;
|
||||
|
||||
// The interval to store stats
|
||||
stats_interval: number;
|
||||
}
|
||||
|
||||
export interface ConfigDatasource {
|
||||
// The type of datasource
|
||||
type: 'local' | 's3';
|
||||
|
||||
// The local datasource, the default
|
||||
local: ConfigLocalDatasource;
|
||||
|
||||
// The s3 datasource
|
||||
s3?: ConfigS3Datasource;
|
||||
}
|
||||
|
||||
export interface ConfigLocalDatasource {
|
||||
// The directory to store files in
|
||||
directory: string;
|
||||
}
|
||||
|
||||
export interface ConfigS3Datasource {
|
||||
// The access key id for the s3 bucket
|
||||
access_key_id: string;
|
||||
|
||||
// The secret access key for the s3 bucket
|
||||
secret_access_key: string;
|
||||
|
||||
// Not required, but if using a non-aws S3 service you can specify the endpoint
|
||||
endpoint?: string;
|
||||
|
||||
// The S3 bucket to store files in
|
||||
bucket: string;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
export interface ConfigUploader {
|
||||
// The route uploads will be served on
|
||||
route: string;
|
||||
|
||||
// Length of random chars to generate for file names
|
||||
length: number;
|
||||
|
||||
// Admin file upload limit
|
||||
admin_limit: number;
|
||||
|
||||
// User file upload limit
|
||||
user_limit: number;
|
||||
|
||||
// Disabled extensions to block from uploading
|
||||
disabled_extensions: string[];
|
||||
}
|
||||
|
||||
export interface ConfigUrls {
|
||||
// The route urls will be served on
|
||||
route: string;
|
||||
|
||||
// Length of random chars to generate for urls
|
||||
length: number;
|
||||
}
|
||||
|
||||
// Ratelimiting for users/admins, setting them to 0 disables ratelimiting
|
||||
export interface ConfigRatelimit {
|
||||
// Ratelimit for users
|
||||
user: number;
|
||||
|
||||
// Ratelimit for admins
|
||||
admin: number;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
core: ConfigCore;
|
||||
uploader: ConfigUploader;
|
||||
urls: ConfigUrls;
|
||||
ratelimit: ConfigRatelimit;
|
||||
datasource: ConfigDatasource;
|
||||
}
|
||||
127
src/lib/config/readConfig.ts
Normal file
127
src/lib/config/readConfig.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
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 });
|
||||
|
||||
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),
|
||||
|
||||
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),
|
||||
|
||||
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 = []),
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
for (let i = 0, L = envValues.length; i !== L; ++i) {
|
||||
const envValue = envValues[i];
|
||||
let value: any = process.env[envValue.val];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
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(',');
|
||||
}
|
||||
62
src/lib/config/validateConfig.ts
Normal file
62
src/lib/config/validateConfig.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Config } from 'lib/config/Config';
|
||||
import { object, bool, string, number, boolean, array } from 'yup';
|
||||
|
||||
const validator = object({
|
||||
core: object({
|
||||
secure: bool().default(false),
|
||||
secret: string().min(8).required(),
|
||||
host: string().default('0.0.0.0'),
|
||||
port: number().default(3000),
|
||||
database_url: string().required(),
|
||||
logger: boolean().default(false),
|
||||
stats_interval: number().default(1800),
|
||||
}).required(),
|
||||
datasource: object({
|
||||
type: string().default('local'),
|
||||
local: object({
|
||||
directory: string().default('./uploads'),
|
||||
}),
|
||||
s3: object({
|
||||
access_key_id: string(),
|
||||
secret_access_key: string(),
|
||||
endpoint: string().notRequired().nullable(),
|
||||
bucket: string(),
|
||||
force_s3_path: boolean().default(false),
|
||||
}).notRequired(),
|
||||
}).required(),
|
||||
uploader: object({
|
||||
route: string().default('/u'),
|
||||
embed_route: string().default('/a'),
|
||||
length: number().default(6),
|
||||
admin_limit: number().default(104900000),
|
||||
user_limit: number().default(104900000),
|
||||
disabled_extensions: array().default([]),
|
||||
}).required(),
|
||||
urls: object({
|
||||
route: string().default('/go'),
|
||||
length: number().default(6),
|
||||
}).required(),
|
||||
ratelimit: object({
|
||||
user: number().default(0),
|
||||
admin: number().default(0),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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')}`;
|
||||
}
|
||||
};
|
||||
20
src/lib/datasource.ts
Normal file
20
src/lib/datasource.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import config from './config';
|
||||
import { S3, Local } 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');
|
||||
}
|
||||
}
|
||||
|
||||
export default global.datasource;
|
||||
10
src/lib/datasources/Datasource.ts
Normal file
10
src/lib/datasources/Datasource.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export abstract class Datasource {
|
||||
public name: string;
|
||||
|
||||
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>;
|
||||
}
|
||||
43
src/lib/datasources/Local.ts
Normal file
43
src/lib/datasources/Local.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createReadStream, existsSync, ReadStream } from 'fs';
|
||||
import { readdir, rm, stat, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { Datasource } from './';
|
||||
|
||||
export class Local extends Datasource {
|
||||
public name: string = 'local';
|
||||
|
||||
public constructor(public path: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async save(file: string, data: Buffer): Promise<void> {
|
||||
await writeFile(join(process.cwd(), this.path, file), data);
|
||||
}
|
||||
|
||||
public async delete(file: string): Promise<void> {
|
||||
await rm(join(process.cwd(), this.path, file));
|
||||
}
|
||||
|
||||
public get(file: string): ReadStream {
|
||||
const full = join(process.cwd(), this.path, file);
|
||||
if (!existsSync(full)) return null;
|
||||
|
||||
try {
|
||||
return createReadStream(full);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async size(): Promise<number> {
|
||||
const files = await readdir(this.path);
|
||||
|
||||
let size = 0;
|
||||
for (let i = 0, L = files.length; i !== L; ++i) {
|
||||
const sta = await stat(join(this.path, files[i]));
|
||||
size += sta.size;
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
}
|
||||
75
src/lib/datasources/S3.ts
Normal file
75
src/lib/datasources/S3.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Datasource } from './';
|
||||
import AWS from 'aws-sdk';
|
||||
import { Readable } from 'stream';
|
||||
import { ConfigS3Datasource } from 'lib/config/Config';
|
||||
|
||||
export class S3 extends Datasource {
|
||||
public name: string = 'S3';
|
||||
public s3: AWS.S3;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
3
src/lib/datasources/index.ts
Normal file
3
src/lib/datasources/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { Datasource } from './Datasource';
|
||||
export { Local } from './Local';
|
||||
export { S3 } from './S3';
|
||||
@@ -5,7 +5,7 @@ export default async function useFetch(url: string, method: 'GET' | 'POST' | 'PA
|
||||
const res = await global.fetch(url, {
|
||||
body: body ? JSON.stringify(body) : null,
|
||||
method,
|
||||
headers
|
||||
headers,
|
||||
});
|
||||
|
||||
return res.json();
|
||||
|
||||
@@ -14,21 +14,17 @@ export default function login() {
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
|
||||
const res = await useFetch('/api/user');
|
||||
|
||||
if (res.error) return router.push('/auth/login');
|
||||
if (res.error) return router.push('/auth/login?url=' + router.route);
|
||||
|
||||
dispatch(updateUser(res));
|
||||
|
||||
setUser(res);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && user) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!loading && user) return;
|
||||
load();
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
const { format } = require('fecha');
|
||||
const { yellow, blueBright, magenta, red, cyan } = require('colorette');
|
||||
|
||||
class Logger {
|
||||
static get(clas) {
|
||||
if (typeof clas !== 'function') if (typeof clas !== 'string') throw new Error('not string/function');
|
||||
|
||||
const name = clas.name ?? clas;
|
||||
|
||||
return new Logger(name);
|
||||
}
|
||||
|
||||
constructor (name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
info(message) {
|
||||
console.log(this.formatMessage('INFO', this.name, message));
|
||||
}
|
||||
|
||||
error(error) {
|
||||
console.log(this.formatMessage('ERROR', this.name, error.toString()));
|
||||
}
|
||||
|
||||
formatMessage(level, name, message) {
|
||||
const time = format(new Date(), 'YYYY-MM-DD hh:mm:ss,SSS A');
|
||||
return `${time} ${this.formatLevel(level)} [${blueBright(name)}] ${message}`;
|
||||
}
|
||||
|
||||
formatLevel(level) {
|
||||
switch (level) {
|
||||
case 'INFO':
|
||||
return cyan('INFO ');
|
||||
case 'DEBUG':
|
||||
return yellow('DEBUG');
|
||||
case 'WARN':
|
||||
return magenta('WARN ');
|
||||
case 'ERROR':
|
||||
return red('ERROR');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Logger;
|
||||
45
src/lib/logger.ts
Normal file
45
src/lib/logger.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { format } from 'fecha';
|
||||
import { blueBright, red, cyan } from 'colorette';
|
||||
|
||||
export enum LoggerLevel {
|
||||
ERROR,
|
||||
INFO,
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
const name = clas.name ?? clas;
|
||||
|
||||
return new Logger(name);
|
||||
}
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
info(...args: any[]) {
|
||||
console.log(this.formatMessage(LoggerLevel.INFO, this.name, args.join(' ')));
|
||||
}
|
||||
|
||||
error(...args: any[]) {
|
||||
console.log(this.formatMessage(LoggerLevel.ERROR, this.name, args.map(error => error.stack ?? error).join(' ')));
|
||||
}
|
||||
|
||||
formatMessage(level: LoggerLevel, name: string, message: string) {
|
||||
const time = format(new Date(), 'YYYY-MM-DD hh:mm:ss,SSS A');
|
||||
return `${time} ${this.formatLevel(level)} [${blueBright(name)}] ${message}`;
|
||||
}
|
||||
|
||||
formatLevel(level: LoggerLevel) {
|
||||
switch (level) {
|
||||
case LoggerLevel.INFO:
|
||||
return cyan('INFO ');
|
||||
case LoggerLevel.ERROR:
|
||||
return red('ERROR');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { CookieSerializeOptions } from 'cookie';
|
||||
import type { Image, Theme, User } from '@prisma/client';
|
||||
|
||||
import { serialize } from 'cookie';
|
||||
import { sign64, unsign64 } from '../util';
|
||||
@@ -12,7 +11,7 @@ export interface NextApiFile {
|
||||
originalname: string;
|
||||
encoding: string;
|
||||
mimetype: string;
|
||||
buffer: string;
|
||||
buffer: Buffer;
|
||||
size: number;
|
||||
}
|
||||
|
||||
@@ -23,52 +22,62 @@ export type NextApiReq = NextApiRequest & {
|
||||
embedTitle: string;
|
||||
embedColor: string;
|
||||
systemTheme: string;
|
||||
customTheme?: Theme;
|
||||
administrator: boolean;
|
||||
id: number;
|
||||
images: Image[];
|
||||
password: string;
|
||||
domains: string[];
|
||||
} | null | void>;
|
||||
getCookie: (name: string) => string | null;
|
||||
cleanCookie: (name: string) => void;
|
||||
file?: NextApiFile;
|
||||
files?: NextApiFile[];
|
||||
}
|
||||
|
||||
export type NextApiRes = NextApiResponse & {
|
||||
error: (message: string) => void;
|
||||
forbid: (message: string) => void;
|
||||
forbid: (message: string, extra?: any) => void;
|
||||
bad: (message: string) => void;
|
||||
json: (json: any) => void;
|
||||
ratelimited: (remaining: number) => void;
|
||||
setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void;
|
||||
}
|
||||
|
||||
export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse) => unknown) => (req: NextApiReq, res: NextApiRes) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Content-Allow-Methods', 'GET,HEAD,POST,OPTIONS');
|
||||
res.setHeader('Access-Control-Max-Age', '86400');
|
||||
|
||||
res.error = (message: string) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.json({
|
||||
error: message
|
||||
error: message,
|
||||
});
|
||||
};
|
||||
|
||||
res.forbid = (message: string) => {
|
||||
res.forbid = (message: string, extra: any = {}) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.status(403);
|
||||
res.json({
|
||||
error: '403: ' + message
|
||||
error: '403: ' + message,
|
||||
...extra,
|
||||
});
|
||||
};
|
||||
|
||||
res.bad = (message: string) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.status(401);
|
||||
res.json({
|
||||
error: '403: ' + message
|
||||
error: '403: ' + message,
|
||||
});
|
||||
};
|
||||
|
||||
res.ratelimited = (remaining: number) => {
|
||||
res.status(429);
|
||||
res.setHeader('X-Ratelimit-Remaining', Math.floor(remaining / 1000));
|
||||
res.json({
|
||||
error: '429: ratelimited',
|
||||
});
|
||||
};
|
||||
|
||||
res.json = (json: any) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
res.end(JSON.stringify(json));
|
||||
};
|
||||
|
||||
@@ -83,9 +92,10 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
||||
res.setHeader('Set-Cookie', serialize(name, '', {
|
||||
path: '/',
|
||||
expires: new Date(1),
|
||||
maxAge: undefined
|
||||
maxAge: undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
req.user = async () => {
|
||||
try {
|
||||
const userId = req.getCookie('user');
|
||||
@@ -93,20 +103,19 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: Number(userId)
|
||||
id: Number(userId),
|
||||
},
|
||||
select: {
|
||||
administrator: true,
|
||||
embedColor: true,
|
||||
embedTitle: true,
|
||||
id: true,
|
||||
images: true,
|
||||
password: true,
|
||||
systemTheme: true,
|
||||
customTheme: true,
|
||||
token: true,
|
||||
username: true
|
||||
}
|
||||
username: true,
|
||||
domains: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
@@ -132,11 +141,11 @@ export const setCookie = (
|
||||
) => {
|
||||
|
||||
if ('maxAge' in options) {
|
||||
options.expires = new Date(Date.now() + options.maxAge);
|
||||
options.expires = new Date(Date.now() + options.maxAge * 1000);
|
||||
options.maxAge /= 1000;
|
||||
}
|
||||
|
||||
const signed = sign64(String(value), config.core.secret);
|
||||
|
||||
res.setHeader('Set-Cookie', serialize(name, signed, options));
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
if (!global.prisma) global.prisma = new PrismaClient();
|
||||
if (!global.prisma) {
|
||||
if (!process.env.ZIPLINE_DOCKER_BUILD) global.prisma = new PrismaClient();
|
||||
};
|
||||
|
||||
export default global.prisma;
|
||||
@@ -1,83 +0,0 @@
|
||||
const { existsSync, readFileSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
const Logger = require('./logger');
|
||||
|
||||
const e = (val, type, fn) => ({ val, type, fn });
|
||||
|
||||
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_TYPE', 'string', (c, v) => c.database.type = v),
|
||||
e('DATABASE_URL', 'string', (c, v) => c.database.url = v),
|
||||
e('UPLOADER_ROUTE', 'string', (c, v) => c.uploader.route = v),
|
||||
e('UPLOADER_EMBED_ROUTE', 'string', (c, v) => c.uploader.embed_route = v),
|
||||
e('UPLOADER_LENGTH', 'number', (c, v) => c.uploader.length = v),
|
||||
e('UPLOADER_DIRECTORY', 'string', (c, v) => c.uploader.directory = v)
|
||||
];
|
||||
|
||||
module.exports = () => {
|
||||
if (!existsSync(join(process.cwd(), 'config.toml'))) {
|
||||
Logger.get('config').info('reading environment');
|
||||
return tryReadEnv();
|
||||
} else {
|
||||
Logger.get('config').info('reading config file');
|
||||
const str = readFileSync(join(process.cwd(), 'config.toml'), 'utf8');
|
||||
const parsed = require('@iarna/toml/parse-string')(str);
|
||||
|
||||
return parsed;
|
||||
}
|
||||
};
|
||||
|
||||
function tryReadEnv() {
|
||||
const config = {
|
||||
core: {
|
||||
secure: undefined,
|
||||
secret: undefined,
|
||||
host: undefined,
|
||||
port: undefined
|
||||
},
|
||||
database: {
|
||||
type: undefined,
|
||||
url: undefined
|
||||
},
|
||||
uploader: {
|
||||
route: undefined,
|
||||
embed_route: undefined,
|
||||
length: undefined,
|
||||
directory: undefined
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0, L = envValues.length; i !== L; ++i) {
|
||||
const envValue = envValues[i];
|
||||
let value = process.env[envValue.val];
|
||||
|
||||
if (!value) {
|
||||
Logger.get('config').error('there is no config file or required environment variables... exiting...');
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
envValues[i].fn(config, value);
|
||||
if (envValue.type === 'number') value = parseToNumber(value);
|
||||
else if (envValue.type === 'boolean') value = parseToBoolean(value);
|
||||
envValues[i].fn(config, value);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Theme } from '@prisma/client';
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
export interface User {
|
||||
@@ -6,8 +5,9 @@ export interface User {
|
||||
token: string;
|
||||
embedTitle: string;
|
||||
embedColor: string;
|
||||
embedSiteName: string;
|
||||
systemTheme: string;
|
||||
customTheme?: Theme;
|
||||
domains: string[];
|
||||
}
|
||||
|
||||
const initialState: User = null;
|
||||
|
||||
@@ -1,54 +1,13 @@
|
||||
// https://github.com/mikecao/umami/blob/master/redux/store.js
|
||||
import { useMemo } from 'react';
|
||||
import { Action, CombinedState, configureStore, EnhancedStore } from '@reduxjs/toolkit';
|
||||
import thunk, { ThunkAction } from 'redux-thunk';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import rootReducer from './reducers';
|
||||
import { User } from './reducers/user';
|
||||
import { useDispatch, TypedUseSelectorHook, useSelector } from 'react-redux';
|
||||
|
||||
let store: EnhancedStore<CombinedState<{
|
||||
user: User;
|
||||
}>>;
|
||||
|
||||
export function getStore(preloadedState) {
|
||||
return configureStore({
|
||||
reducer: rootReducer,
|
||||
middleware: [thunk],
|
||||
preloadedState,
|
||||
});
|
||||
}
|
||||
|
||||
export const initializeStore = preloadedState => {
|
||||
let _store = store ?? getStore(preloadedState);
|
||||
|
||||
if (preloadedState && store) {
|
||||
_store = getStore({
|
||||
...store.getState(),
|
||||
...preloadedState,
|
||||
});
|
||||
store = undefined;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') return _store;
|
||||
if (!store) store = _store;
|
||||
|
||||
return _store;
|
||||
};
|
||||
|
||||
export function useStore(initialState?: User) {
|
||||
return useMemo(() => initializeStore(initialState), [initialState]);
|
||||
}
|
||||
export const store = configureStore({
|
||||
reducer: rootReducer,
|
||||
});
|
||||
|
||||
export type AppState = ReturnType<typeof store.getState>
|
||||
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
|
||||
export type AppThunk<ReturnType = void> = ThunkAction<
|
||||
ReturnType,
|
||||
AppState,
|
||||
unknown,
|
||||
Action<User>
|
||||
>
|
||||
|
||||
export const useStoreDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useStoreSelector: TypedUseSelectorHook<AppState> = useSelector;
|
||||
@@ -3,15 +3,36 @@
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'dark',
|
||||
primary: '#E6B450',
|
||||
secondary: '#FFEE99',
|
||||
error: '#F07178',
|
||||
warning: '#F29668',
|
||||
info: '#95E6CB',
|
||||
border: '#0D1016',
|
||||
background: {
|
||||
main: '#0A0E14',
|
||||
paper: '#0D1016'
|
||||
}
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'orange',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#0a0e14',
|
||||
hover: '#191e29',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#ffffff',
|
||||
'#47494E',
|
||||
'#6c707a',
|
||||
'#33353B',
|
||||
'#303238',
|
||||
'#2C2E34',
|
||||
'#25272D',
|
||||
'#0d1016',
|
||||
'#11141A',
|
||||
'#0D1016',
|
||||
],
|
||||
orange: [
|
||||
'#FFFFFF',
|
||||
'#FCF6EA',
|
||||
'#F9EDD4',
|
||||
'#F3DAA8',
|
||||
'#F2D69D',
|
||||
'#F0D192',
|
||||
'#EFCC87',
|
||||
'#EDC77C',
|
||||
'#EABE66',
|
||||
'#E6B450',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -3,15 +3,24 @@
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'light',
|
||||
primary: '#FF9940',
|
||||
secondary: '#E6BA7E',
|
||||
error: '#F07171',
|
||||
warning: '#ED9366',
|
||||
info: '#95E6CB',
|
||||
border: '#FFFFFF',
|
||||
background: {
|
||||
main: '#FAFAFA',
|
||||
paper: '#FFFFFF'
|
||||
}
|
||||
colorScheme: 'light',
|
||||
primaryColor: 'orange',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#FAFAFA',
|
||||
hover: '#FAFAFA',
|
||||
},
|
||||
colors: {
|
||||
orange: [
|
||||
'#FFFFFF',
|
||||
'#FCF6EA',
|
||||
'#F9EDD4',
|
||||
'#F3DAA8',
|
||||
'#F2D69D',
|
||||
'#F0D192',
|
||||
'#EFCC87',
|
||||
'#EDC77C',
|
||||
'#EABE66',
|
||||
'#E6B450',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -3,15 +3,36 @@
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'dark',
|
||||
primary: '#FFCC66',
|
||||
secondary: '#FFD580',
|
||||
error: '#F28779',
|
||||
warning: '#F29E74',
|
||||
info: '#95E6CB',
|
||||
border: '#232834',
|
||||
background: {
|
||||
main: '#1F2430',
|
||||
paper: '#232834'
|
||||
}
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'orange',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#1F2430',
|
||||
hover: '#2a2f3b',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#ffffff',
|
||||
'#91949A',
|
||||
'#6c707a',
|
||||
'#3F434E',
|
||||
'#313641',
|
||||
'#2A2F3B',
|
||||
'#2e333e',
|
||||
'#232834',
|
||||
'#11141A',
|
||||
'#0D1016',
|
||||
],
|
||||
orange: [
|
||||
'#FFFFFF',
|
||||
'#FCF6EA',
|
||||
'#F9EDD4',
|
||||
'#F3DAA8',
|
||||
'#F2D69D',
|
||||
'#F0D192',
|
||||
'#EFCC87',
|
||||
'#EDC77C',
|
||||
'#EABE66',
|
||||
'#E6B450',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -1,15 +1,36 @@
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'dark',
|
||||
primary: '#2c39a6',
|
||||
secondary: '#7344e2',
|
||||
error: '#ff4141',
|
||||
warning: '#ff9800',
|
||||
info: '#2f6fb9',
|
||||
border: '#2b2b2b',
|
||||
background: {
|
||||
main: '#000000',
|
||||
paper: '#060606'
|
||||
}
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'blue',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#000000',
|
||||
hover: '#2b2b2b',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#ffffff',
|
||||
'#A7A9AD',
|
||||
'#7B7E84',
|
||||
'#61646A',
|
||||
'#54575D',
|
||||
'#46494F',
|
||||
'#3C3F44',
|
||||
'#060606',
|
||||
'#141517',
|
||||
'#000000',
|
||||
],
|
||||
blue: [
|
||||
'#FFFFFF',
|
||||
'#7C7DC2',
|
||||
'#7778C0',
|
||||
'#6C6FBC',
|
||||
'#575DB5',
|
||||
'#4D54B2',
|
||||
'#424BAE',
|
||||
'#3742AA',
|
||||
'#323EA8',
|
||||
'#2C39A6',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -1,15 +1,36 @@
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'dark',
|
||||
primary: '#2c39a6',
|
||||
secondary: '#7344e2',
|
||||
error: '#ff4141',
|
||||
warning: '#ff9800',
|
||||
info: '#2f6fb9',
|
||||
border: '#1b2541',
|
||||
background: {
|
||||
main: '#05070f',
|
||||
paper: '#0c101c'
|
||||
}
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'blue',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#05070f',
|
||||
hover: '#181c28',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#FFFFFF',
|
||||
'#293747',
|
||||
'#6C7A8D',
|
||||
'#232F41',
|
||||
'#41566e',
|
||||
'#171F35',
|
||||
'#181c28',
|
||||
'#0c101c',
|
||||
'#060824',
|
||||
'#00001E',
|
||||
],
|
||||
blue: [
|
||||
'#FFFFFF',
|
||||
'#7C7DC2',
|
||||
'#7778C0',
|
||||
'#6C6FBC',
|
||||
'#575DB5',
|
||||
'#4D54B2',
|
||||
'#424BAE',
|
||||
'#3742AA',
|
||||
'#323EA8',
|
||||
'#2C39A6',
|
||||
],
|
||||
},
|
||||
});
|
||||
38
src/lib/themes/dracula.ts
Normal file
38
src/lib/themes/dracula.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// https://github.com/AlphaNecron/
|
||||
// https://github.com/dracula/dracula-theme
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'violet',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#282A36',
|
||||
hover: '#4e5062',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#FFFFFF',
|
||||
'#CED0D4',
|
||||
'#E8E8EB',
|
||||
'#D1D1D6',
|
||||
'#BABAC2',
|
||||
'#A2A3AD',
|
||||
'#4e5062',
|
||||
'#44475A',
|
||||
'#5C5E6F',
|
||||
'#44475A',
|
||||
],
|
||||
violet: [
|
||||
'#FFFFFF',
|
||||
'#F7F2FF',
|
||||
'#EFE4FE',
|
||||
'#EBDEFE',
|
||||
'#E7D7FD',
|
||||
'#DEC9FC',
|
||||
'#D6BCFC',
|
||||
'#CEAEFB',
|
||||
'#C6A1FA',
|
||||
'#BD93F9',
|
||||
],
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user