mirror of
https://github.com/diced/zipline.git
synced 2025-12-16 09:30:54 -08:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
uploads/
|
||||||
|
.git/
|
||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
.github/workflows/build.yml
vendored
3
.github/workflows/build.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
|||||||
key: ${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
|
key: ${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
|
||||||
|
|
||||||
- name: Create mock config
|
- 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
|
- name: Install dependencies
|
||||||
if: steps.cache-restore.outputs.cache-hit != 'true'
|
if: steps.cache-restore.outputs.cache-hit != 'true'
|
||||||
@@ -31,4 +31,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: yarn build
|
run: yarn build
|
||||||
|
|
||||||
1
.github/workflows/docker.yml
vendored
1
.github/workflows/docker.yml
vendored
@@ -7,6 +7,7 @@ on:
|
|||||||
- 'src/**'
|
- 'src/**'
|
||||||
- 'server/**'
|
- 'server/**'
|
||||||
- 'prisma/**'
|
- 'prisma/**'
|
||||||
|
- '.github/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,6 +18,7 @@
|
|||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
.idea
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
@@ -36,4 +37,3 @@ yarn-error.log*
|
|||||||
# zipline
|
# zipline
|
||||||
config.toml
|
config.toml
|
||||||
uploads/
|
uploads/
|
||||||
data.db*
|
|
||||||
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
|
|
||||||
33
Dockerfile
33
Dockerfile
@@ -1,31 +1,46 @@
|
|||||||
FROM node:16-alpine3.11 AS builder
|
FROM node:16-alpine AS deps
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
RUN yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM node:16-alpine AS builder
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY --from=deps /build/node_modules ./node_modules
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY server ./server
|
COPY server ./server
|
||||||
COPY scripts ./scripts
|
COPY scripts ./scripts
|
||||||
COPY prisma ./prisma
|
COPY prisma ./prisma
|
||||||
|
|
||||||
COPY package.json yarn.lock 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
|
ENV ZIPLINE_DOCKER_BUILD 1
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
# create a mock config.toml to spoof next build!
|
|
||||||
RUN echo -e "[uploader]\nroute = '/u'\nembed_route = '/a'\nlength = 6\ndirectory = './uploads'" > config.toml
|
|
||||||
|
|
||||||
RUN yarn build
|
RUN yarn build
|
||||||
|
|
||||||
FROM node:16-alpine3.11 AS runner
|
FROM node:16-alpine AS runner
|
||||||
WORKDIR /zipline
|
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/node_modules ./node_modules
|
||||||
|
|
||||||
|
COPY --from=builder /build/next.config.js ./next.config.js
|
||||||
COPY --from=builder /build/src ./src
|
COPY --from=builder /build/src ./src
|
||||||
COPY --from=builder /build/server ./server
|
COPY --from=builder /build/server ./server
|
||||||
COPY --from=builder /build/scripts ./scripts
|
COPY --from=builder /build/scripts ./scripts
|
||||||
COPY --from=builder /build/prisma ./prisma
|
COPY --from=builder /build/prisma ./prisma
|
||||||
COPY --from=builder /build/.next ./.next
|
|
||||||
COPY --from=builder /build/tsconfig.json ./tsconfig.json
|
COPY --from=builder /build/tsconfig.json ./tsconfig.json
|
||||||
COPY --from=builder /build/package.json ./package.json
|
COPY --from=builder /build/package.json ./package.json
|
||||||
|
|
||||||
|
USER zipline
|
||||||
|
|
||||||
CMD ["node", "server"]
|
CMD ["node", "server"]
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
prisma
|
|
||||||
node_modules
|
|
||||||
.next
|
|
||||||
uploads
|
|
||||||
.git
|
|
||||||
16
README.md
16
README.md
@@ -1,7 +1,7 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="https://raw.githubusercontent.com/diced/zipline/trunk/public/zipline_small.png"/>
|
<img src="https://raw.githubusercontent.com/diced/zipline/trunk/public/zipline_small.png"/>
|
||||||
|
|
||||||
Zipline is a file sharing, URL sharing, lightweight and easy to use!
|
Zipline is a ShareX/file upload server that is easy to use, packed with features and can be setup in one command!
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
@@ -16,13 +16,19 @@
|
|||||||
- Fast
|
- Fast
|
||||||
- Built with Next.js & React
|
- Built with Next.js & React
|
||||||
- Token protected uploading
|
- Token protected uploading
|
||||||
- Easy setup instructions on [docs](https://zipline.diced.me) (One command install `docker-compose up`)
|
- Image uploading
|
||||||
|
- URL shortening
|
||||||
|
- Text uploading
|
||||||
|
- URL Formats (uuid, dates, random alphanumeric, original name, zws)
|
||||||
|
- Discord embeds (OG metadata)
|
||||||
|
- Gallery viewer, and multiple file format support
|
||||||
|
- Easy setup instructions on [docs](https://zipline.diced.tech/) (One command install `docker-compose up -d`)
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
[See how to install here](https://zipline.diced.me/get-started)
|
[See how to install here](https://zipline.diced.tech/docs/get-started)
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
[See how to configure here](https://zipline.diced.me/configuration/overview)
|
[See how to configure here](https://zipline.diced.tech/docs/config/overview)
|
||||||
|
|
||||||
## Theming
|
## Theming
|
||||||
[See how to theme here](https://zipline.diced.me/themes)
|
[See how to theme here](https://zipline.diced.tech/docs/themes/reference)
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| ------- | ------------------ |
|
| ------- | ------------------ |
|
||||||
| 3.x.x | :white_check_mark: |
|
| 3.2.x | :white_check_mark: |
|
||||||
|
| < 3 | :x: |
|
||||||
| < 2 | :x: |
|
| < 2 | :x: |
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|||||||
@@ -1,55 +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',
|
|
||||||
'config',
|
|
||||||
'api',
|
|
||||||
'hooks',
|
|
||||||
'components',
|
|
||||||
'middleware',
|
|
||||||
'redux',
|
|
||||||
'themes',
|
|
||||||
'lib',
|
|
||||||
'assets'
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -5,6 +5,10 @@ host = '0.0.0.0'
|
|||||||
port = 3000
|
port = 3000
|
||||||
database_url = 'postgres://postgres:postgres@postgres/postgres'
|
database_url = 'postgres://postgres:postgres@postgres/postgres'
|
||||||
|
|
||||||
|
[urls]
|
||||||
|
route = '/go'
|
||||||
|
length = 6
|
||||||
|
|
||||||
[uploader]
|
[uploader]
|
||||||
route = '/u'
|
route = '/u'
|
||||||
embed_route = '/a'
|
embed_route = '/a'
|
||||||
|
|||||||
46
docker-compose.dev.yml
Normal file
46
docker-compose.dev.yml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres
|
||||||
|
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
|
||||||
|
- 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/public:/zipline/public'
|
||||||
|
depends_on:
|
||||||
|
- 'postgres'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data:
|
||||||
@@ -32,6 +32,8 @@ services:
|
|||||||
- UPLOADER_ADMIN_LIMIT=104900000
|
- UPLOADER_ADMIN_LIMIT=104900000
|
||||||
- UPLOADER_USER_LIMIT=104900000
|
- UPLOADER_USER_LIMIT=104900000
|
||||||
- UPLOADER_DISABLED_EXTS=
|
- UPLOADER_DISABLED_EXTS=
|
||||||
|
- URLS_ROUTE=/go
|
||||||
|
- URLS_LENGTH=6
|
||||||
volumes:
|
volumes:
|
||||||
- '$PWD/uploads:/zipline/uploads'
|
- '$PWD/uploads:/zipline/uploads'
|
||||||
- '$PWD/public:/zipline/public'
|
- '$PWD/public:/zipline/public'
|
||||||
|
|||||||
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
@@ -1,5 +1,4 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/types/global" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
reactStrictMode: true,
|
async redirects() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/',
|
||||||
|
destination: '/dashboard',
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
51
package.json
51
package.json
@@ -1,60 +1,63 @@
|
|||||||
{
|
{
|
||||||
"name": "zip3",
|
"name": "zip3",
|
||||||
"version": "3.2.2",
|
"version": "3.4.0",
|
||||||
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky install",
|
|
||||||
"dev": "NODE_ENV=development node server",
|
"dev": "NODE_ENV=development node server",
|
||||||
"build": "npm-run-all build:schema build:next",
|
"build": "npm-run-all build:schema build:next",
|
||||||
"build:next": "next build",
|
"build:next": "next build",
|
||||||
"build:schema": "prisma generate --schema=prisma/schema.prisma",
|
"build:schema": "prisma generate --schema=prisma/schema.prisma",
|
||||||
|
"migrate:dev": "prisma migrate dev --create-only",
|
||||||
"start": "node server",
|
"start": "node server",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"ts-node": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only",
|
"seed": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only prisma/seed.ts",
|
||||||
"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": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.4.0",
|
|
||||||
"@emotion/styled": "^11.3.0",
|
|
||||||
"@iarna/toml": "2.2.5",
|
"@iarna/toml": "2.2.5",
|
||||||
"@material-ui/core": "^5.0.0-alpha.37",
|
"@mantine/core": "^3.6.9",
|
||||||
"@material-ui/icons": "^5.0.0-alpha.37",
|
"@mantine/dropzone": "^3.6.9",
|
||||||
"@material-ui/styles": "^5.0.0-alpha.35",
|
"@mantine/hooks": "^3.6.9",
|
||||||
"@prisma/client": "^3.0.2",
|
"@mantine/modals": "^3.6.9",
|
||||||
|
"@mantine/next": "^3.6.9",
|
||||||
|
"@mantine/notifications": "^3.6.9",
|
||||||
|
"@mantine/prism": "^3.6.11",
|
||||||
|
"@modulz/radix-icons": "^4.0.0",
|
||||||
|
"@prisma/client": "^3.9.2",
|
||||||
|
"@prisma/migrate": "^3.9.2",
|
||||||
|
"@prisma/sdk": "^3.9.2",
|
||||||
"@reduxjs/toolkit": "^1.6.0",
|
"@reduxjs/toolkit": "^1.6.0",
|
||||||
"argon2": "^0.28.2",
|
"argon2": "^0.28.2",
|
||||||
"colorette": "^1.2.2",
|
"colorette": "^1.2.2",
|
||||||
"cookie": "^0.4.1",
|
"cookie": "^0.4.1",
|
||||||
"copy-to-clipboard": "^3.3.1",
|
|
||||||
"fecha": "^4.2.1",
|
"fecha": "^4.2.1",
|
||||||
"formik": "^2.2.9",
|
|
||||||
"multer": "^1.4.2",
|
"multer": "^1.4.2",
|
||||||
"next": "11.1.1",
|
"next": "^12.1.0",
|
||||||
"prisma": "^3.0.2",
|
"prisma": "^3.9.2",
|
||||||
"react": "17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-dropzone": "^11.3.2",
|
|
||||||
"react-redux": "^7.2.4",
|
"react-redux": "^7.2.4",
|
||||||
|
"react-table": "^7.7.0",
|
||||||
"redux": "^4.1.0",
|
"redux": "^4.1.0",
|
||||||
"redux-thunk": "^2.3.0",
|
"redux-thunk": "^2.3.0",
|
||||||
|
"uuid": "^8.3.2",
|
||||||
"yup": "^0.32.9"
|
"yup": "^0.32.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^12.1.4",
|
|
||||||
"@commitlint/config-conventional": "^12.1.4",
|
|
||||||
"@types/cookie": "^0.4.0",
|
"@types/cookie": "^0.4.0",
|
||||||
"@types/multer": "^1.4.6",
|
"@types/multer": "^1.4.6",
|
||||||
"@types/node": "^15.12.2",
|
"@types/node": "^15.12.2",
|
||||||
"babel-plugin-transform-imports": "^2.0.0",
|
"babel-plugin-import": "^1.13.3",
|
||||||
"eslint": "7.28.0",
|
"eslint": "^7.32.0",
|
||||||
"eslint-config-next": "11.0.0",
|
"eslint-config-next": "11.0.0",
|
||||||
"husky": "^6.0.0",
|
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"release": "^6.3.0",
|
|
||||||
"ts-node": "^10.0.0",
|
"ts-node": "^10.0.0",
|
||||||
"typescript": "^4.3.2"
|
"typescript": "^4.3.2"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/diced/workflow-testing.git"
|
"url": "https://github.com/diced/zipline.git"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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";
|
||||||
@@ -13,27 +13,20 @@ model User {
|
|||||||
password String
|
password String
|
||||||
token String
|
token String
|
||||||
administrator Boolean @default(false)
|
administrator Boolean @default(false)
|
||||||
systemTheme String @default("dark_blue")
|
systemTheme String @default("system")
|
||||||
customTheme Theme?
|
|
||||||
embedTitle String?
|
embedTitle String?
|
||||||
embedColor String @default("#2f3136")
|
embedColor String @default("#2f3136")
|
||||||
|
embedSiteName String? @default("{image.file} • {user.name}")
|
||||||
|
ratelimited Boolean @default(false)
|
||||||
images Image[]
|
images Image[]
|
||||||
urls Url[]
|
urls Url[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Theme {
|
enum ImageFormat {
|
||||||
id Int @id @default(autoincrement())
|
UUID
|
||||||
type String
|
DATE
|
||||||
primary String
|
RANDOM
|
||||||
secondary String
|
NAME
|
||||||
error String
|
|
||||||
warning String
|
|
||||||
info String
|
|
||||||
border String
|
|
||||||
mainBackground String
|
|
||||||
paperBackground String
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
userId Int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Image {
|
model Image {
|
||||||
@@ -45,6 +38,7 @@ model Image {
|
|||||||
favorite Boolean @default(false)
|
favorite Boolean @default(false)
|
||||||
embed Boolean @default(false)
|
embed Boolean @default(false)
|
||||||
invisible InvisibleImage?
|
invisible InvisibleImage?
|
||||||
|
format ImageFormat @default(RANDOM)
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
userId Int
|
userId Int
|
||||||
}
|
}
|
||||||
@@ -52,14 +46,14 @@ model Image {
|
|||||||
model InvisibleImage {
|
model InvisibleImage {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
invis String @unique
|
invis String @unique
|
||||||
|
|
||||||
imageId Int
|
imageId Int
|
||||||
image Image @relation(fields: [imageId], references: [id])
|
image Image @relation(fields: [imageId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Url {
|
model Url {
|
||||||
id Int @id @default(autoincrement())
|
id String @id @unique
|
||||||
to String
|
destination String
|
||||||
|
vanity String?
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
views Int @default(0)
|
views Int @default(0)
|
||||||
invisible InvisibleUrl?
|
invisible InvisibleUrl?
|
||||||
@@ -68,8 +62,14 @@ model Url {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model InvisibleUrl {
|
model InvisibleUrl {
|
||||||
id Int
|
id Int @id @default(autoincrement())
|
||||||
url Url @relation(fields: [id], references: [id])
|
|
||||||
|
|
||||||
invis String @unique
|
invis String @unique
|
||||||
|
urlId String
|
||||||
|
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',
|
username: 'administrator',
|
||||||
password: await hashPassword('password'),
|
password: await hashPassword('password'),
|
||||||
token: createToken(),
|
token: createToken(),
|
||||||
administrator: true
|
administrator: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`
|
console.log(`
|
||||||
|
|||||||
@@ -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.core.database_url, ['migrate', 'deploy']);
|
|
||||||
await prismaRun(config.core.database_url, ['generate'], true);
|
|
||||||
} 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',
|
||||||
|
};
|
||||||
@@ -20,7 +20,7 @@ const { PrismaClient } = require('@prisma/client');
|
|||||||
return {
|
return {
|
||||||
file: x,
|
file: x,
|
||||||
mimetype: mime,
|
mimetype: mime,
|
||||||
userId: 1
|
userId: 1,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ const { PrismaClient } = require('@prisma/client');
|
|||||||
|
|
||||||
Logger.get('migrator').info('starting migrations...');
|
Logger.get('migrator').info('starting migrations...');
|
||||||
await prisma.image.createMany({
|
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);
|
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();
|
process.exit();
|
||||||
|
|||||||
@@ -74,5 +74,5 @@ module.exports = {
|
|||||||
'.zip': 'application/zip',
|
'.zip': 'application/zip',
|
||||||
'.3gp': 'video/3gpp',
|
'.3gp': 'video/3gpp',
|
||||||
'.3g2': 'video/3gpp2',
|
'.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, nostdout = false) => {
|
|
||||||
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 => {
|
|
||||||
if (!nostdout) console.log(d.toString());
|
|
||||||
a += d.toString();
|
|
||||||
});
|
|
||||||
proc.stderr.on('data', d => {
|
|
||||||
if (!nostdout) console.log(d.toString());
|
|
||||||
rej(d.toString());
|
|
||||||
});
|
|
||||||
proc.stdout.on('end', () => res(a));
|
|
||||||
proc.stdout.on('close', () => res(a));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
142
server/index.js
142
server/index.js
@@ -1,62 +1,48 @@
|
|||||||
const next = require('next');
|
const next = require('next').default;
|
||||||
const { createServer } = require('http');
|
const { createServer } = require('http');
|
||||||
const { stat, mkdir } = require('fs/promises');
|
const { mkdir } = require('fs/promises');
|
||||||
const { execSync } = require('child_process');
|
|
||||||
const { extname } = require('path');
|
const { extname } = require('path');
|
||||||
const { red, green, bold } = require('colorette');
|
|
||||||
const { PrismaClient } = require('@prisma/client');
|
|
||||||
const validateConfig = require('./validateConfig');
|
const validateConfig = require('./validateConfig');
|
||||||
const Logger = require('../src/lib/logger');
|
const Logger = require('../src/lib/logger');
|
||||||
const getFile = require('./static');
|
|
||||||
const prismaRun = require('../scripts/prisma-run');
|
|
||||||
const readConfig = require('../src/lib/readConfig');
|
const readConfig = require('../src/lib/readConfig');
|
||||||
const mimes = require('../scripts/mimes');
|
const mimes = require('../scripts/mimes');
|
||||||
const deployDb = require('../scripts/deploy-db');
|
const { log, getStats, getFile, migrations } = require('./util');
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
const { version } = require('../package.json');
|
const { version } = require('../package.json');
|
||||||
|
const exts = require('../scripts/exts');
|
||||||
|
const serverLog = Logger.get('server');
|
||||||
|
|
||||||
Logger.get('server').info(`starting zipline@${version} server`);
|
serverLog.info(`starting zipline@${version} server`);
|
||||||
|
|
||||||
const dev = process.env.NODE_ENV === 'development';
|
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 () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const config = readConfig();
|
await run();
|
||||||
await validateConfig(config);
|
} catch (e) {
|
||||||
|
serverLog.error(e);
|
||||||
const data = await prismaRun(config.core.database_url, ['migrate', 'status'], true);
|
process.exit(1);
|
||||||
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');
|
|
||||||
} else {
|
|
||||||
Logger.get('database').info('migrations up to date');
|
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const a = readConfig();
|
||||||
|
const config = validateConfig(a);
|
||||||
|
|
||||||
process.env.DATABASE_URL = config.core.database_url;
|
process.env.DATABASE_URL = config.core.database_url;
|
||||||
|
await migrations();
|
||||||
|
|
||||||
await mkdir(config.uploader.directory, { recursive: true });
|
await mkdir(config.uploader.directory, { recursive: true });
|
||||||
|
|
||||||
const app = next({
|
const app = next({
|
||||||
dir: '.',
|
dir: '.',
|
||||||
dev,
|
dev,
|
||||||
quiet: dev
|
quiet: !dev,
|
||||||
}, config.core.port, config.core.host);
|
hostname: config.core.host,
|
||||||
|
port: config.core.port,
|
||||||
|
});
|
||||||
|
|
||||||
await app.prepare();
|
await app.prepare();
|
||||||
await stat('./.next');
|
|
||||||
|
|
||||||
const handle = app.getRequestHandler();
|
const handle = app.getRequestHandler();
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
@@ -70,15 +56,15 @@ function shouldUseYarn() {
|
|||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{ file: parts[2] },
|
{ file: parts[2] },
|
||||||
{ invisible:{ invis: decodeURI(parts[2]) } }
|
{ invisible:{ invis: decodeURI(parts[2]) } },
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
mimetype: true,
|
mimetype: true,
|
||||||
id: true,
|
id: true,
|
||||||
file: true,
|
file: true,
|
||||||
invisible: true
|
invisible: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!image) {
|
if (!image) {
|
||||||
@@ -89,52 +75,90 @@ function shouldUseYarn() {
|
|||||||
res.setHeader('Content-Type', mimetype);
|
res.setHeader('Content-Type', mimetype);
|
||||||
res.end(data);
|
res.end(data);
|
||||||
} else {
|
} else {
|
||||||
if (image) {
|
|
||||||
const data = await getFile(config.uploader.directory, image.file);
|
const data = await getFile(config.uploader.directory, image.file);
|
||||||
if (!data) return app.render404(req, res);
|
if (!data) return app.render404(req, res);
|
||||||
|
|
||||||
await prisma.image.update({
|
await prisma.image.update({
|
||||||
where: { id: image.id },
|
where: { id: image.id },
|
||||||
data: { views: { increment: 1 } }
|
data: { views: { increment: 1 } },
|
||||||
});
|
});
|
||||||
res.setHeader('Content-Type', image.mimetype);
|
res.setHeader('Content-Type', image.mimetype);
|
||||||
res.end(data);
|
res.end(data);
|
||||||
} else {
|
}
|
||||||
|
} else if (req.url.startsWith(config.uploader.route)) {
|
||||||
|
const parts = req.url.split('/');
|
||||||
|
if (!parts[2] || parts[2] === '') return;
|
||||||
|
|
||||||
|
let image = await prisma.image.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ file: parts[2] },
|
||||||
|
{ invisible:{ invis: decodeURI(parts[2]) } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
mimetype: true,
|
||||||
|
id: true,
|
||||||
|
file: true,
|
||||||
|
invisible: true,
|
||||||
|
embed: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
const data = await getFile(config.uploader.directory, parts[2]);
|
const data = await getFile(config.uploader.directory, parts[2]);
|
||||||
if (!data) return app.render404(req, res);
|
if (!data) return app.render404(req, res);
|
||||||
|
|
||||||
const mimetype = mimes[extname(parts[2])] ?? 'application/octet-stream';
|
const mimetype = mimes[extname(parts[2])] ?? 'application/octet-stream';
|
||||||
res.setHeader('Content-Type', mimetype);
|
res.setHeader('Content-Type', mimetype);
|
||||||
res.end(data);
|
res.end(data);
|
||||||
}
|
} else if (image.embed) {
|
||||||
|
handle(req, res);
|
||||||
|
} else {
|
||||||
|
const ext = image.file.split('.').pop();
|
||||||
|
if (Object.keys(exts).includes(ext)) return handle(req, res);
|
||||||
|
const data = await getFile(config.uploader.directory, image.file);
|
||||||
|
if (!data) return app.render404(req, res);
|
||||||
|
|
||||||
|
await prisma.image.update({
|
||||||
|
where: { id: image.id },
|
||||||
|
data: { views: { increment: 1 } },
|
||||||
|
});
|
||||||
|
res.setHeader('Content-Type', image.mimetype);
|
||||||
|
res.end(data);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
handle(req, res);
|
handle(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
log(req.url, res.statusCode);
|
if (config.core.logger) log(req.url, res.statusCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
srv.on('error', (e) => {
|
srv.on('error', (e) => {
|
||||||
Logger.get('server').error(e);
|
serverLog.error(e);
|
||||||
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
srv.on('listening', () => {
|
srv.on('listening', () => {
|
||||||
Logger.get('server').info(`listening on ${config.core.host}:${config.core.port}`);
|
serverLog.info(`listening on ${config.core.host}:${config.core.port}`);
|
||||||
if (process.platform === 'linux' && dev) execSync(`xdg-open ${config.core.secure ? 'https' : 'http'}://${config.core.host === '0.0.0.0' ? 'localhost' : config.core.host}:${config.core.port}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
srv.listen(config.core.port, config.core.host ?? '0.0.0.0');
|
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')) {
|
const stats = await getStats(prisma, config);
|
||||||
Logger.get('web').error(`there is no production build - run \`${shouldUseYarn() ? 'yarn build' : 'npm build'}\``);
|
await prisma.stats.create({
|
||||||
} else if (e.code && e.code === 'ENOENT') {
|
data: {
|
||||||
if (e.path === './.next') Logger.get('web').error(`there is no production build - run \`${shouldUseYarn() ? 'yarn build' : 'npm build'}\``);
|
data: stats,
|
||||||
} else {
|
},
|
||||||
Logger.get('server').error(e);
|
});
|
||||||
process.exit(1);
|
setInterval(async () => {
|
||||||
}
|
const stats = await getStats(prisma, config);
|
||||||
}
|
await prisma.stats.create({
|
||||||
})();
|
data: {
|
||||||
|
data: stats,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (config.core.logger) serverLog.info('stats updated');
|
||||||
|
}, config.core.stats_interval * 1000);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
130
server/util.js
Normal file
130
server/util.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
const { readFile, readdir, stat } = require('fs/promises');
|
||||||
|
const { join } = require('path');
|
||||||
|
const { Migrate } = require('@prisma/migrate/dist/Migrate.js');
|
||||||
|
const Logger = require('../src/lib/logger.js');
|
||||||
|
|
||||||
|
async function migrations() {
|
||||||
|
const migrate = new Migrate('./prisma/schema.prisma');
|
||||||
|
const diagnose = await migrate.diagnoseMigrationHistory({
|
||||||
|
optInToShadowDatabase: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (diagnose.history?.diagnostic === 'databaseIsBehind') {
|
||||||
|
Logger.get('database').info('migrating database');
|
||||||
|
await migrate.applyMigrations();
|
||||||
|
Logger.get('database').info('finished migrating database');
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(url) {
|
||||||
|
if (url.startsWith('/_next') || url.startsWith('/__nextjs')) return;
|
||||||
|
return Logger.get('url').info(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseYarn() {
|
||||||
|
try {
|
||||||
|
execSync('yarnpkg --version', { stdio: 'ignore' });
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function getFile(dir, file) {
|
||||||
|
try {
|
||||||
|
const data = await readFile(join(process.cwd(), dir, file));
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sizeOfDir(directory) {
|
||||||
|
const files = await readdir(directory);
|
||||||
|
|
||||||
|
let size = 0;
|
||||||
|
for (let i = 0, L = files.length; i !== L; ++i) {
|
||||||
|
const sta = await stat(join(directory, files[i]));
|
||||||
|
size += sta.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToRead(bytes) {
|
||||||
|
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
let num = 0;
|
||||||
|
|
||||||
|
while (bytes > 1024) {
|
||||||
|
bytes /= 1024;
|
||||||
|
++num;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function getStats(prisma, config) {
|
||||||
|
const size = await sizeOfDir(join(process.cwd(), config.uploader.directory));
|
||||||
|
const byUser = await prisma.image.groupBy({
|
||||||
|
by: ['userId'],
|
||||||
|
_count: {
|
||||||
|
_all: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const count_users = await prisma.user.count();
|
||||||
|
|
||||||
|
const count_by_user = [];
|
||||||
|
for (let i = 0, L = byUser.length; i !== L; ++i) {
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
id: byUser[i].userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
count_by_user.push({
|
||||||
|
username: user.username,
|
||||||
|
count: byUser[i]._count._all,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await prisma.image.count();
|
||||||
|
const viewsCount = await prisma.image.groupBy({
|
||||||
|
by: ['views'],
|
||||||
|
_sum: {
|
||||||
|
views: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const typesCount = await prisma.image.groupBy({
|
||||||
|
by: ['mimetype'],
|
||||||
|
_count: {
|
||||||
|
mimetype: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const types_count = [];
|
||||||
|
for (let i = 0, L = typesCount.length; i !== L; ++i) types_count.push({ mimetype: typesCount[i].mimetype, count: typesCount[i]._count.mimetype });
|
||||||
|
|
||||||
|
return {
|
||||||
|
size: bytesToRead(size),
|
||||||
|
size_num: size,
|
||||||
|
count,
|
||||||
|
count_by_user: count_by_user.sort((a,b) => b.count-a.count),
|
||||||
|
count_users,
|
||||||
|
views_count: (viewsCount[0]?._sum?.views ?? 0),
|
||||||
|
types_count: types_count.sort((a,b) => b.count-a.count),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
migrations,
|
||||||
|
bytesToRead,
|
||||||
|
getFile,
|
||||||
|
getStats,
|
||||||
|
log,
|
||||||
|
sizeOfDir,
|
||||||
|
shouldUseYarn,
|
||||||
|
};
|
||||||
@@ -1,45 +1,40 @@
|
|||||||
const Logger = require('../src/lib/logger');
|
const { object, bool, string, number, boolean, array } = require('yup');
|
||||||
|
|
||||||
function dot(str, obj) {
|
const validator = object({
|
||||||
return str.split('.').reduce((a,b) => a[b], obj);
|
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(),
|
||||||
|
uploader: object({
|
||||||
|
route: string().default('/u'),
|
||||||
|
embed_route: string().default('/a'),
|
||||||
|
length: number().default(6),
|
||||||
|
directory: string().default('./uploads'),
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const path = (path, type) => ({ path, type });
|
|
||||||
|
|
||||||
module.exports = async config => {
|
module.exports = function validate(config) {
|
||||||
const paths = [
|
try {
|
||||||
path('core.secure', 'boolean'),
|
return validator.validateSync(config, { abortEarly: false });
|
||||||
path('core.secret', 'string'),
|
} catch (e) {
|
||||||
path('core.host', 'string'),
|
if (process.env.ZIPLINE_DOCKER_BUILD) return {};
|
||||||
path('core.port', 'number'),
|
throw `${e.errors.length} errors occured\n${e.errors.map(x => '\t' + x).join('\n')}`;
|
||||||
path('core.database_url', 'string'),
|
|
||||||
path('uploader.route', 'string'),
|
|
||||||
path('uploader.length', 'number'),
|
|
||||||
path('uploader.directory', 'string'),
|
|
||||||
path('uploader.admin_limit', 'number'),
|
|
||||||
path('uploader.user_limit', 'number'),
|
|
||||||
path('uploader.disabled_extentions', 'object'),
|
|
||||||
];
|
|
||||||
|
|
||||||
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 which was required`);
|
|
||||||
++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 React from 'react';
|
||||||
import {
|
import { LoadingOverlay } from '@mantine/core';
|
||||||
Backdrop as MuiBackdrop,
|
|
||||||
CircularProgress
|
|
||||||
} from '@material-ui/core';
|
|
||||||
|
|
||||||
export default function Backdrop({ open }) {
|
export default function Backdrop({ open }) {
|
||||||
return (
|
return (
|
||||||
<MuiBackdrop
|
<LoadingOverlay visible={open} />
|
||||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
|
||||||
open={open}
|
|
||||||
>
|
|
||||||
<CircularProgress color='inherit' />
|
|
||||||
</MuiBackdrop>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
Card as MuiCard,
|
Card as MCard,
|
||||||
CardContent,
|
Title,
|
||||||
Typography
|
} from '@mantine/core';
|
||||||
} from '@material-ui/core';
|
|
||||||
|
|
||||||
export default function Card(props) {
|
export default function Card(props) {
|
||||||
const { name, children, ...other } = props;
|
const { name, children, ...other } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MuiCard sx={{ minWidth: '100%' }} {...other}>
|
<MCard padding='md' shadow='sm' {...other}>
|
||||||
<CardContent>
|
<Title order={2}>{name}</Title>
|
||||||
<Typography variant='h3'>{name}</Typography>
|
|
||||||
{children}
|
{children}
|
||||||
</CardContent>
|
</MCard>
|
||||||
</MuiCard>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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,83 +1,94 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardMedia,
|
|
||||||
CardActionArea,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent
|
|
||||||
} from '@material-ui/core';
|
|
||||||
import AudioIcon from '@material-ui/icons/Audiotrack';
|
|
||||||
import copy from 'copy-to-clipboard';
|
|
||||||
import useFetch from '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 }) {
|
export default function Image({ image, updateImages }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [t,] = useState(image.mimetype.split('/')[0]);
|
const [t] = useState(image.mimetype.split('/')[0]);
|
||||||
|
const notif = useNotifications();
|
||||||
|
const clipboard = useClipboard();
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
|
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
|
||||||
if (!res.error) updateImages(true);
|
if (!res.error) {
|
||||||
|
updateImages(true);
|
||||||
|
notif.showNotification({
|
||||||
|
title: 'Image Deleted',
|
||||||
|
message: '',
|
||||||
|
color: 'green',
|
||||||
|
icon: <TrashIcon />,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
notif.showNotification({
|
||||||
|
title: 'Failed to delete image',
|
||||||
|
message: res.error,
|
||||||
|
color: 'red',
|
||||||
|
icon: <Cross1Icon />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
copy(`${window.location.protocol}//${window.location.host}${image.url}`);
|
clipboard.copy(`${window.location.protocol}//${window.location.host}${image.url}`);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
notif.showNotification({
|
||||||
|
title: 'Copied to clipboard',
|
||||||
|
message: '',
|
||||||
|
icon: <CopyIcon />,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFavorite = async () => {
|
const handleFavorite = async () => {
|
||||||
const data = await useFetch('/api/user/files', 'PATCH', { id: image.id, favorite: !image.favorite });
|
const data = await useFetch('/api/user/files', 'PATCH', { id: image.id, favorite: !image.favorite });
|
||||||
if (!data.error) updateImages(true);
|
if (!data.error) updateImages(true);
|
||||||
|
notif.showNotification({
|
||||||
|
title: 'Image is now ' + (!image.favorite ? 'favorited' : 'unfavorited'),
|
||||||
|
message: '',
|
||||||
|
icon: <StarIcon />,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const Type = (props) => {
|
const Type = (props) => {
|
||||||
return {
|
return {
|
||||||
'video': <video controls {...props} />,
|
'video': <video controls {...props} />,
|
||||||
// eslint-disable-next-line jsx-a11y/alt-text
|
'image': <MImage {...props} />,
|
||||||
'image': <img {...props} />,
|
'audio': <audio controls {...props} />,
|
||||||
'audio': <audio controls {...props} />
|
|
||||||
}[t];
|
}[t];
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card sx={{ maxWidth: '100%' }}>
|
<Modal
|
||||||
<CardActionArea sx={t === 'audio' ? { justifyContent: 'center', display: 'flex', alignItems: 'center' } : {}}>
|
opened={open}
|
||||||
<CardMedia
|
onClose={() => setOpen(false)}
|
||||||
sx={{ height: 320, fontSize: 70, width: '100%' }}
|
title={<Title>{image.file}</Title>}
|
||||||
image={image.url}
|
>
|
||||||
title={image.file}
|
<Type
|
||||||
component={t === 'audio' ? AudioIcon : t} // this is done because audio without controls is hidden
|
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)}
|
onClick={() => setOpen(true)}
|
||||||
/>
|
/>
|
||||||
</CardActionArea>
|
</Card.Section>
|
||||||
</Card>
|
</Card>
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
<DialogTitle id='alert-dialog-title'>
|
|
||||||
{image.file}
|
|
||||||
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Type
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
src={image.url}
|
|
||||||
alt={image.url}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={handleDelete} color='inherit'>Delete</Button>
|
|
||||||
<Button onClick={handleCopy} color='inherit'>Copy URL</Button>
|
|
||||||
<Button onClick={handleFavorite} color='inherit'>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
159
src/components/ImagesTable.tsx
Normal file
159
src/components/ImagesTable.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/* 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,
|
||||||
|
Checkbox,
|
||||||
|
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,405 +1,329 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
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,
|
|
||||||
Folder as FolderIcon,
|
|
||||||
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 { useRouter } from 'next/router';
|
||||||
import { useStoreDispatch } from 'lib/redux/store';
|
import { useStoreDispatch } from 'lib/redux/store';
|
||||||
import { updateUser } from 'lib/redux/reducers/user';
|
import { updateUser } from 'lib/redux/reducers/user';
|
||||||
import useFetch from 'hooks/useFetch';
|
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 = [
|
const items = [
|
||||||
{
|
{
|
||||||
icon: <HomeIcon />,
|
icon: <HomeIcon />,
|
||||||
text: 'Home',
|
text: 'Home',
|
||||||
link: '/dashboard'
|
link: '/dashboard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <FolderIcon />,
|
icon: <FileIcon />,
|
||||||
text: 'Files',
|
text: 'Files',
|
||||||
link: '/dashboard/files'
|
link: '/dashboard/files',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <MixerHorizontalIcon />,
|
||||||
|
text: 'Stats',
|
||||||
|
link: '/dashboard/stats',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Link1Icon />,
|
||||||
|
text: 'URLs',
|
||||||
|
link: '/dashboard/urls',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <UploadIcon />,
|
icon: <UploadIcon />,
|
||||||
text: 'Upload',
|
text: 'Upload',
|
||||||
link: '/dashboard/upload'
|
link: '/dashboard/upload',
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const drawerWidth = 240;
|
export default function Layout({ children, user }) {
|
||||||
|
|
||||||
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);
|
|
||||||
const [token, setToken] = useState(user?.token);
|
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 router = useRouter();
|
||||||
const dispatch = useStoreDispatch();
|
const dispatch = useStoreDispatch();
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
const modals = useModals();
|
||||||
|
const notif = useNotifications();
|
||||||
|
const clipboard = useClipboard();
|
||||||
|
|
||||||
const open = Boolean(anchorEl);
|
const handleUpdateTheme = async value => {
|
||||||
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 newUser = await useFetch('/api/user', 'PATCH', {
|
const newUser = await useFetch('/api/user', 'PATCH', {
|
||||||
systemTheme: event.target.value || 'dark_blue'
|
systemTheme: value || 'dark_blue',
|
||||||
});
|
});
|
||||||
|
|
||||||
setSystemTheme(newUser.systemTheme);
|
setSystemTheme(newUser.systemTheme);
|
||||||
dispatch(updateUser(newUser));
|
dispatch(updateUser(newUser));
|
||||||
|
|
||||||
router.replace(router.pathname);
|
router.replace(router.pathname);
|
||||||
|
|
||||||
|
notif.showNotification({
|
||||||
|
title: `Theme changed to ${friendlyThemeName[value]}`,
|
||||||
|
message: '',
|
||||||
|
color: 'green',
|
||||||
|
icon: <Pencil1Icon />,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const drawer = (
|
const openResetToken = () => modals.openConfirmModal({
|
||||||
<div>
|
title: 'Reset Token',
|
||||||
<CopyTokenDialog open={copyOpen} setOpen={setCopyOpen} token={token} />
|
children: (
|
||||||
<ResetTokenDialog open={resetOpen} setOpen={setResetOpen} setToken={setToken} />
|
<Text size='sm'>
|
||||||
<Toolbar
|
Once you reset your token, you will have to update any uploaders to use this new token.
|
||||||
sx={{
|
</Text>
|
||||||
width: { xs: drawerWidth }
|
),
|
||||||
}}
|
labels: { confirm: 'Reset', cancel: 'Cancel' },
|
||||||
>
|
onConfirm: async () => {
|
||||||
<AppBar
|
const a = await useFetch('/api/user/token', 'PATCH');
|
||||||
position='fixed'
|
if (!a.success) {
|
||||||
elevation={0}
|
setToken(a.success);
|
||||||
sx={{
|
notif.showNotification({
|
||||||
borderBottom: 1,
|
title: 'Token Reset Failed',
|
||||||
borderBottomColor: t => t.palette.divider,
|
message: a.error,
|
||||||
display: { xs: 'none', sm: 'block' }
|
color: 'red',
|
||||||
}}
|
icon: <Cross1Icon />,
|
||||||
>
|
});
|
||||||
<Toolbar>
|
} else {
|
||||||
<IconButton
|
notif.showNotification({
|
||||||
color='inherit'
|
title: 'Token Reset',
|
||||||
aria-label='open drawer'
|
message: 'Your token has been reset. You will need to update any uploaders to use this new token.',
|
||||||
edge='start'
|
color: 'green',
|
||||||
onClick={() => setMobileOpen(true)}
|
icon: <CheckIcon />,
|
||||||
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' passHref>
|
|
||||||
<MenuItem onClick={handleClose(null)}>
|
|
||||||
<AccountIcon sx={{ mr: 2 }} /> Manage Account
|
|
||||||
</MenuItem>
|
|
||||||
</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' passHref>
|
|
||||||
<MenuItem onClick={handleClose(null)}>
|
|
||||||
<LogoutIcon sx={{ mr: 2 }} /> Logout
|
|
||||||
</MenuItem>
|
|
||||||
</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} passHref>
|
|
||||||
<ListItem button>
|
|
||||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
|
||||||
<ListItemText primary={item.text} />
|
|
||||||
</ListItem>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
{user && user.administrator && (
|
|
||||||
<Link href='/dashboard/users' passHref>
|
|
||||||
<ListItem button>
|
|
||||||
<ListItemIcon><UsersIcon /></ListItemIcon>
|
|
||||||
<ListItemText primary='Users' />
|
|
||||||
</ListItem>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
|
|
||||||
</div>
|
modals.closeAll();
|
||||||
);
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const container = typeof window !== 'undefined' ? window.document.body : undefined;
|
const openCopyToken = () => modals.openConfirmModal({
|
||||||
|
title: 'Copy Token',
|
||||||
|
children: (
|
||||||
|
<Text size='sm'>
|
||||||
|
Make sure you don't share this token with anyone as they will be able to upload files on your behalf.
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
labels: { confirm: 'Copy', cancel: 'Cancel' },
|
||||||
|
onConfirm: async () => {
|
||||||
|
clipboard.copy(token);
|
||||||
|
|
||||||
|
notif.showNotification({
|
||||||
|
title: 'Token Copied',
|
||||||
|
message: 'Your token has been copied to your clipboard.',
|
||||||
|
color: 'green',
|
||||||
|
icon: <CheckIcon />,
|
||||||
|
});
|
||||||
|
|
||||||
|
modals.closeAll();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex' }}>
|
<AppShell
|
||||||
<Backdrop open={loading} />
|
navbarOffsetBreakpoint='sm'
|
||||||
|
fixed
|
||||||
|
navbar={
|
||||||
|
<Navbar
|
||||||
|
padding='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,
|
||||||
|
|
||||||
<AppBar
|
'&:hover': {
|
||||||
position='fixed'
|
backgroundColor: theme.other.hover,
|
||||||
elevation={0}
|
},
|
||||||
sx={{
|
|
||||||
width: { sm: `calc(100% - ${drawerWidth}px)` },
|
|
||||||
ml: { sm: `${drawerWidth}px` }
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Toolbar>
|
<Group>
|
||||||
<IconButton
|
<ThemeIcon color='primary' variant='filled'>
|
||||||
color='inherit'
|
{icon}
|
||||||
aria-label='open drawer'
|
</ThemeIcon>
|
||||||
edge='start'
|
|
||||||
onClick={() => setMobileOpen(true)}
|
<Text size='lg'>{text}</Text>
|
||||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
</Group>
|
||||||
>
|
</UnstyledButton>
|
||||||
<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}
|
|
||||||
>
|
|
||||||
<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' passHref>
|
|
||||||
<MenuItem onClick={handleClose(null)}>
|
|
||||||
<AccountIcon sx={{ mr: 2 }} /> Manage Account
|
|
||||||
</MenuItem>
|
|
||||||
</Link>
|
</Link>
|
||||||
<MenuItem onClick={handleClose('copy')}>
|
))}
|
||||||
<CopyIcon sx={{ mr: 2 }} /> Copy Token
|
{user.administrator && (
|
||||||
</MenuItem>
|
<Link href='/dashboard/users' passHref>
|
||||||
<MenuItem onClick={handleClose('reset')}>
|
<UnstyledButton
|
||||||
<ResetIcon sx={{ mr: 2 }} /> Reset Token
|
sx={{
|
||||||
</MenuItem>
|
display: 'block',
|
||||||
<Link href='/auth/logout' passHref>
|
width: '100%',
|
||||||
<MenuItem onClick={handleClose(null)}>
|
padding: theme.spacing.xs,
|
||||||
<LogoutIcon sx={{ mr: 2 }} /> Logout
|
borderRadius: theme.radius.sm,
|
||||||
</MenuItem>
|
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>
|
</Link>
|
||||||
</Menu>
|
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
</Toolbar>
|
</Navbar.Section>
|
||||||
</AppBar>
|
</Navbar>
|
||||||
<Box
|
}
|
||||||
component='nav'
|
header={
|
||||||
|
<Header height={70} padding='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={{
|
sx={{
|
||||||
width: { sm: drawerWidth },
|
display: 'block',
|
||||||
flexShrink: { sm: 0 }
|
width: '100%',
|
||||||
|
padding: theme.spacing.xs,
|
||||||
|
borderRadius: theme.radius.sm,
|
||||||
|
color: theme.other.color,
|
||||||
|
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.other.hover,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Drawer
|
<Group>
|
||||||
container={container}
|
<ThemeIcon color='primary' variant='filled'>
|
||||||
variant='temporary'
|
<GearIcon />
|
||||||
onClose={() => setMobileOpen(false)}
|
</ThemeIcon>
|
||||||
open={mobileOpen}
|
<Text>{user.username}</Text>
|
||||||
elevation={0}
|
</Group>
|
||||||
ModalProps={{
|
</UnstyledButton>
|
||||||
keepMounted: true
|
}
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
display: { xs: 'block', sm: 'none' },
|
|
||||||
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth }
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{drawer}
|
<Group direction='column' spacing={2}>
|
||||||
</Drawer>
|
<Text sx={{
|
||||||
<Drawer
|
color: theme.colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6],
|
||||||
variant='permanent'
|
fontWeight: 500,
|
||||||
sx={{
|
fontSize: theme.fontSizes.xs,
|
||||||
display: { xs: 'none', sm: 'block' },
|
padding: `${theme.spacing.xs / 2}px ${theme.spacing.sm}px`,
|
||||||
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth }
|
cursor: 'default',
|
||||||
}}
|
}}>User: {user.username}</Text>
|
||||||
open
|
<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>
|
||||||
|
</div>
|
||||||
|
</Header>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{drawer}
|
<Paper withBorder padding='md' shadow='xs'>{children}</Paper>
|
||||||
</Drawer>
|
</AppShell>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import React, { forwardRef } from 'react';
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import NextLink from 'next/link';
|
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) {
|
export const NextLinkComposed = forwardRef(function NextLinkComposed(props: any, ref) {
|
||||||
const { to, linkAs, href, replace, scroll, passHref, shallow, prefetch, locale, ...other } =
|
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 (isExternal) {
|
||||||
if (noLinkStyle) {
|
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) {
|
if (noLinkStyle) {
|
||||||
@@ -61,8 +61,9 @@ const Link = forwardRef(function Link(props: any, ref) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MuiLink
|
<Text
|
||||||
component={NextLinkComposed}
|
component={NextLinkComposed}
|
||||||
|
variant='link'
|
||||||
linkAs={linkAs}
|
linkAs={linkAs}
|
||||||
className={className}
|
className={className}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -1,71 +1,96 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { ThemeProvider } from '@emotion/react';
|
|
||||||
import { CssBaseline } from '@material-ui/core';
|
|
||||||
|
|
||||||
// themes
|
// themes
|
||||||
import dark_blue from 'lib/themes/dark_blue';
|
import dark_blue from 'lib/themes/dark_blue';
|
||||||
|
import light_blue from 'lib/themes/light_blue';
|
||||||
import dark from 'lib/themes/dark';
|
import dark from 'lib/themes/dark';
|
||||||
import ayu_dark from 'lib/themes/ayu_dark';
|
import ayu_dark from 'lib/themes/ayu_dark';
|
||||||
import ayu_mirage from 'lib/themes/ayu_mirage';
|
import ayu_mirage from 'lib/themes/ayu_mirage';
|
||||||
import ayu_light from 'lib/themes/ayu_light';
|
import ayu_light from 'lib/themes/ayu_light';
|
||||||
import nord from 'lib/themes/nord';
|
import nord from 'lib/themes/nord';
|
||||||
import polar from 'lib/themes/polar';
|
|
||||||
import dracula from 'lib/themes/dracula';
|
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 { 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 = {
|
export const themes = {
|
||||||
'dark_blue': dark_blue,
|
system: (colorScheme: 'dark' | 'light') => colorScheme === 'dark' ? dark_blue : light_blue,
|
||||||
'dark': dark,
|
dark_blue,
|
||||||
'ayu_dark': ayu_dark,
|
light_blue,
|
||||||
'ayu_mirage': ayu_mirage,
|
dark,
|
||||||
'ayu_light': ayu_light,
|
ayu_dark,
|
||||||
'nord': nord,
|
ayu_mirage,
|
||||||
'polar': polar,
|
ayu_light,
|
||||||
'dracula': dracula
|
nord,
|
||||||
|
dracula,
|
||||||
|
matcha_dark_azul,
|
||||||
|
qogir_dark,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const friendlyThemeName = {
|
export const friendlyThemeName = {
|
||||||
|
'system': 'System Theme',
|
||||||
'dark_blue': 'Dark Blue',
|
'dark_blue': 'Dark Blue',
|
||||||
|
'light_blue': 'Light Blue',
|
||||||
'dark': 'Very Dark',
|
'dark': 'Very Dark',
|
||||||
'ayu_dark': 'Ayu Dark',
|
'ayu_dark': 'Ayu Dark',
|
||||||
'ayu_mirage': 'Ayu Mirage',
|
'ayu_mirage': 'Ayu Mirage',
|
||||||
'ayu_light': 'Ayu Light',
|
'ayu_light': 'Ayu Light',
|
||||||
'nord': 'Nord',
|
'nord': 'Nord',
|
||||||
'polar': 'Polar',
|
'dracula': 'Dracula',
|
||||||
'dracula': 'Dracula'
|
'matcha_dark_azul': 'Matcha Dark Azul',
|
||||||
|
'qogir_dark': 'Qogir Dark',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ZiplineTheming({ Component, pageProps }) {
|
export default function ZiplineTheming({ Component, pageProps, ...props }) {
|
||||||
let t;
|
|
||||||
|
|
||||||
const user = useStoreSelector(state => state.user);
|
const user = useStoreSelector(state => state.user);
|
||||||
if (!user) t = themes.dark_blue;
|
const colorScheme = useColorScheme();
|
||||||
else {
|
|
||||||
if (user.customTheme) {
|
let theme: MantineThemeOverride;
|
||||||
t = createTheme({
|
|
||||||
type: 'dark',
|
if (!user) theme = themes.system(colorScheme);
|
||||||
primary: user.customTheme.primary,
|
else if (user.systemTheme === 'system') theme = themes.system(colorScheme);
|
||||||
secondary: user.customTheme.secondary,
|
else theme = themes[user.systemTheme] ?? themes.system(colorScheme);
|
||||||
error: user.customTheme.error,
|
|
||||||
warning: user.customTheme.warning,
|
useEffect(() => {
|
||||||
info: user.customTheme.info,
|
document.documentElement.style.setProperty('color-scheme', theme.colorScheme);
|
||||||
border: user.customTheme.border,
|
}, [user, theme]);
|
||||||
background: {
|
|
||||||
main: user.customTheme.mainBackground,
|
|
||||||
paper: user.customTheme.paperBackground
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
t = themes[user.systemTheme] ?? themes.dark_blue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={t}>
|
<MantineProvider
|
||||||
<CssBaseline />
|
withGlobalStyles
|
||||||
<Component {...pageProps} />
|
withNormalizeCSS
|
||||||
</ThemeProvider>
|
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,27 +1,15 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TablePagination,
|
|
||||||
TableRow,
|
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
|
||||||
Typography,
|
|
||||||
Grid,
|
|
||||||
Skeleton,
|
|
||||||
CardActionArea,
|
|
||||||
CardMedia,
|
|
||||||
Card as MuiCard
|
|
||||||
} from '@material-ui/core';
|
|
||||||
import AudioIcon from '@material-ui/icons/Audiotrack';
|
|
||||||
|
|
||||||
import Link from 'components/Link';
|
|
||||||
import Card from 'components/Card';
|
import Card from 'components/Card';
|
||||||
|
import Image from 'components/Image';
|
||||||
|
import ImagesTable from 'components/ImagesTable';
|
||||||
import useFetch from 'lib/hooks/useFetch';
|
import useFetch from 'lib/hooks/useFetch';
|
||||||
import { useStoreSelector } from 'lib/redux/store';
|
import { useStoreSelector } from 'lib/redux/store';
|
||||||
|
import { Box, Text, Table, 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';
|
||||||
|
|
||||||
type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify';
|
type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify';
|
||||||
|
|
||||||
@@ -39,50 +27,34 @@ export function bytesToRead(bytes: number) {
|
|||||||
return `${bytes.toFixed(1)} ${units[num]}`;
|
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 }) {
|
function StatText({ children }) {
|
||||||
return <Typography variant='h5' color='GrayText'>{children}</Typography>;
|
return <Text color='gray' size='xl'>{children}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatTable({ rows, columns }) {
|
function StatTable({ rows, columns }) {
|
||||||
return (
|
return (
|
||||||
<TableContainer sx={{ pt: 1 }}>
|
<Box sx={{ pt: 1 }}>
|
||||||
<Table sx={{ minWidth: 100 }} size='small'>
|
<Table highlightOnHover>
|
||||||
<TableHead>
|
<thead>
|
||||||
<TableRow>
|
<tr>
|
||||||
{columns.map(col => (
|
{columns.map(col => (
|
||||||
<TableCell key={col.name} sx={{ borderColor: t => t.palette.divider }}>{col.name}</TableCell>
|
<th key={randomId()}>{col.name}</th>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</tr>
|
||||||
</TableHead>
|
</thead>
|
||||||
<TableBody>
|
<tbody>
|
||||||
{rows.map(row => (
|
{rows.map(row => (
|
||||||
<TableRow
|
<tr key={randomId()}>
|
||||||
hover
|
|
||||||
key={row.username}
|
|
||||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
|
||||||
>
|
|
||||||
{columns.map(col => (
|
{columns.map(col => (
|
||||||
<TableCell key={col.id} sx={{ borderColor: t => t.palette.divider }}>
|
<td key={randomId()}>
|
||||||
{col.format ? col.format(row[col.id]) : row[col.id]}
|
{col.format ? col.format(row[col.id]) : row[col.id]}
|
||||||
</TableCell>
|
</td>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,31 +63,51 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
const [images, setImages] = useState([]);
|
const [images, setImages] = useState([]);
|
||||||
const [recent, setRecent] = useState([]);
|
const [recent, setRecent] = useState([]);
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const [stats, setStats] = useState(null);
|
const [stats, setStats] = useState(null);
|
||||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
const clipboard = useClipboard();
|
||||||
|
const notif = useNotifications();
|
||||||
|
|
||||||
const updateImages = async () => {
|
const updateImages = async () => {
|
||||||
const imgs = await useFetch('/api/user/files');
|
const imgs = await useFetch('/api/user/files');
|
||||||
const recent = await useFetch('/api/user/recent?filter=media');
|
const recent = await useFetch('/api/user/recent?filter=media');
|
||||||
const stts = await useFetch('/api/stats');
|
const stts = await useFetch('/api/stats');
|
||||||
setImages(imgs);
|
setImages(imgs.map(x => ({ ...x, created_at: new Date(x.created_at).toLocaleString() })));
|
||||||
setStats(stts);
|
setStats(stts);
|
||||||
setRecent(recent);
|
setRecent(recent);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangePage = (event, newPage) => {
|
const deleteImage = async ({ original }) => {
|
||||||
setPage(newPage);
|
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 => {
|
const copyImage = async ({ original }) => {
|
||||||
setRowsPerPage(+event.target.value);
|
clipboard.copy(`${window.location.protocol}//${window.location.host}${original.url}`);
|
||||||
setPage(0);
|
notif.showNotification({
|
||||||
|
title: 'Copied to clipboard',
|
||||||
|
message: '',
|
||||||
|
icon: <CopyIcon />,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async image => {
|
const viewImage = async ({ original }) => {
|
||||||
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
|
window.open(`${window.location.protocol}//${window.location.host}${original.url}`);
|
||||||
if (!res.error) updateImages();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -124,123 +116,90 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography variant='h4'>Welcome back {user?.username}</Typography>
|
<Title>Welcome back {user?.username}</Title>
|
||||||
<Typography color='GrayText' pb={2}>You have <b>{images.length ? images.length : '...'}</b> images</Typography>
|
<Text color='gray' sx={{ paddingBottom: 4 }}>You have <b>{images.length ? images.length : '...'}</b> files</Text>
|
||||||
|
|
||||||
<Typography variant='h4'>Recent Images</Typography>
|
<Title>Recent Files</Title>
|
||||||
<Grid container spacing={4} py={2}>
|
<SimpleGrid
|
||||||
{recent.length ? recent.map(image => (
|
cols={4}
|
||||||
<Grid item xs={12} sm={3} key={image.id}>
|
spacing='lg'
|
||||||
<MuiCard sx={{ minWidth: '100%' }}>
|
breakpoints={[
|
||||||
<CardActionArea>
|
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||||
<CardMedia
|
]}
|
||||||
sx={{ height: 220 }}
|
|
||||||
image={image.url}
|
|
||||||
title={image.file}
|
|
||||||
controls
|
|
||||||
component={image.mimetype.split('/')[0] === 'audio' ? AudioIcon : image.mimetype.split('/')[0]} // this is done because audio without controls is hidden
|
|
||||||
/>
|
|
||||||
</CardActionArea>
|
|
||||||
</MuiCard>
|
|
||||||
</Grid>
|
|
||||||
)) : [1,2,3,4].map(x => (
|
|
||||||
<Grid item xs={12} sm={3} key={x}>
|
|
||||||
<Skeleton variant='rectangular' width='100%' height={220} sx={{ borderRadius: 1 }}/>
|
|
||||||
</Grid>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
<Typography variant='h4'>Stats</Typography>
|
|
||||||
<Grid container spacing={4} py={2}>
|
|
||||||
<Grid item xs={12} sm={4}>
|
|
||||||
<Card name='Size' sx={{ height: '100%' }}>
|
|
||||||
<StatText>{stats ? stats.size : <Skeleton variant='text' />}</StatText>
|
|
||||||
<Typography variant='h3'>Average Size</Typography>
|
|
||||||
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton variant='text' />}</StatText>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} sm={4}>
|
|
||||||
<Card name='Images' sx={{ height: '100%' }}>
|
|
||||||
<StatText>{stats ? stats.count : <Skeleton variant='text' />}</StatText>
|
|
||||||
<Typography variant='h3'>Views</Typography>
|
|
||||||
<StatText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? '0' : stats.views_count / stats.count})` : <Skeleton variant='text' />}</StatText>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} sm={4}>
|
|
||||||
<Card name='Users' sx={{ height: '100%' }}>
|
|
||||||
<StatText>{stats ? stats.count_users : <Skeleton variant='text' />}</StatText>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
<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, borderColor: t => t.palette.divider }}
|
|
||||||
>
|
>
|
||||||
{column.label}
|
{recent.length ? recent.map(image => (
|
||||||
</TableCell>
|
// eslint-disable-next-line jsx-a11y/alt-text
|
||||||
|
<Image key={randomId()} image={image} updateImages={updateImages} />
|
||||||
|
)) : [1,2,3,4].map(x => (
|
||||||
|
<div key={x}>
|
||||||
|
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
<TableCell sx={{ minWidth: 200, borderColor: t => t.palette.divider }} align='right'>
|
</SimpleGrid>
|
||||||
Actions
|
|
||||||
</TableCell>
|
<Title mt='md'>Stats</Title>
|
||||||
</TableRow>
|
<Text>View more stats here <Link href='/dashboard/stats'>here</Link>.</Text>
|
||||||
</TableHead>
|
<SimpleGrid
|
||||||
<TableBody>
|
cols={3}
|
||||||
{images
|
spacing='lg'
|
||||||
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
|
breakpoints={[
|
||||||
.map(row => {
|
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||||
return (
|
]}
|
||||||
<TableRow hover role='checkbox' tabIndex={-1} key={row.id}>
|
>
|
||||||
{columns.map(column => {
|
<Card name='Size' sx={{ height: '100%' }}>
|
||||||
const value = row[column.id];
|
<StatText>{stats ? stats.size : <Skeleton height={8} />}</StatText>
|
||||||
return (
|
<Title order={2}>Average Size</Title>
|
||||||
<TableCell key={column.id} align={column.align} sx={{ borderColor: t => t.palette.divider }}>
|
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</StatText>
|
||||||
{column.format ? column.format(value) : value}
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<TableCell align='right' sx={{ borderColor: t => t.palette.divider }}>
|
|
||||||
<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} />
|
|
||||||
</Card>
|
</Card>
|
||||||
<Card name='Images per User' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'>
|
<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
|
<StatTable
|
||||||
columns={[
|
columns={[
|
||||||
{ id: 'username', name: 'Name' },
|
{ id: 'username', name: 'Name' },
|
||||||
{ id: 'count', name: 'Images' }
|
{ id: 'count', name: 'Files' },
|
||||||
]}
|
]}
|
||||||
rows={stats ? stats.count_by_user : []} />
|
rows={stats ? stats.count_by_user : []} />
|
||||||
</Card>
|
</Card>
|
||||||
<Card name='Types' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'>
|
<Card name='Types' mt={22}>
|
||||||
<StatTable
|
<StatTable
|
||||||
columns={[
|
columns={[
|
||||||
{ id: 'mimetype', name: 'Type' },
|
{ id: 'mimetype', name: 'Type' },
|
||||||
{ id: 'count', name: 'Count' }
|
{ id: 'count', name: 'Count' },
|
||||||
]}
|
]}
|
||||||
rows={stats ? stats.types_count : []} />
|
rows={stats ? stats.types_count : []} />
|
||||||
</Card>
|
</Card> */}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,27 +1,24 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Grid, Pagination, Box, Typography, Accordion, AccordionSummary, AccordionDetails } from '@material-ui/core';
|
|
||||||
import { ExpandMore } from '@material-ui/icons';
|
|
||||||
|
|
||||||
import Backdrop from 'components/Backdrop';
|
|
||||||
import ZiplineImage from 'components/Image';
|
import ZiplineImage from 'components/Image';
|
||||||
import useFetch from 'hooks/useFetch';
|
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() {
|
export default function Files() {
|
||||||
const [pages, setPages] = useState([]);
|
const [pages, setPages] = useState([]);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [favoritePages, setFavoritePages] = useState([]);
|
const [favoritePages, setFavoritePages] = useState([]);
|
||||||
const [favoritePage, setFavoritePage] = useState(1);
|
const [favoritePage, setFavoritePage] = useState(1);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const updatePages = async favorite => {
|
const updatePages = async favorite => {
|
||||||
setLoading(true);
|
|
||||||
const pages = await useFetch('/api/user/files?paged=true&filter=media');
|
const pages = await useFetch('/api/user/files?paged=true&filter=media');
|
||||||
if (favorite) {
|
if (favorite) {
|
||||||
const fPages = await useFetch('/api/user/files?paged=true&favorite=media');
|
const fPages = await useFetch('/api/user/files?paged=true&favorite=media');
|
||||||
setFavoritePages(fPages);
|
setFavoritePages(fPages);
|
||||||
}
|
}
|
||||||
setPages(pages);
|
setPages(pages);
|
||||||
setLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -30,59 +27,76 @@ export default function Files() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Backdrop open={loading}/>
|
<Group>
|
||||||
{!pages.length ? (
|
<Title sx={{ marginBottom: 12 }}>Files</Title>
|
||||||
<Box
|
<Link href='/dashboard/upload' passHref>
|
||||||
display='flex'
|
<ActionIcon component='a' variant='filled' color='primary'><PlusIcon/></ActionIcon>
|
||||||
justifyContent='center'
|
</Link>
|
||||||
alignItems='center'
|
</Group>
|
||||||
pt={2}
|
<Accordion
|
||||||
pb={3}
|
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' },
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Typography variant='h4'>No Files</Typography>
|
|
||||||
</Box>
|
|
||||||
) : <Typography variant='h4'>Files</Typography>}
|
|
||||||
{favoritePages.length ? (
|
|
||||||
<Accordion sx={{ my: 2, border: 1, borderColor: t => t.palette.divider }} elevation={0}>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
|
||||||
<Typography variant='h4'>Favorite Files</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
{favoritePages.length ? favoritePages[(favoritePage - 1) ?? 0].map(image => (
|
{favoritePages.length ? favoritePages[(favoritePage - 1) ?? 0].map(image => (
|
||||||
<Grid item xs={12} sm={3} key={image.id}>
|
<div key={image.id}>
|
||||||
<ZiplineImage image={image} updateImages={() => updatePages(true)} />
|
<ZiplineImage image={image} updateImages={() => updatePages(true)} />
|
||||||
</Grid>
|
</div>
|
||||||
)) : null}
|
)) : null}
|
||||||
</Grid>
|
</SimpleGrid>
|
||||||
{favoritePages.length ? (
|
|
||||||
<Box
|
<Box
|
||||||
display='flex'
|
sx={{
|
||||||
justifyContent='center'
|
display: 'flex',
|
||||||
alignItems='center'
|
justifyContent: 'center',
|
||||||
pt={2}
|
alignItems: 'center',
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 3,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Pagination count={favoritePages.length} page={favoritePage} onChange={(_, v) => setFavoritePage(v)}/>
|
<Pagination total={favoritePages.length} page={favoritePage} onChange={setFavoritePage}/>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
</Accordion.Item>
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
</Accordion>
|
||||||
) : null}
|
<SimpleGrid
|
||||||
<Grid container spacing={2}>
|
cols={3}
|
||||||
|
spacing='lg'
|
||||||
|
breakpoints={[
|
||||||
|
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
{pages.length ? pages[(page - 1) ?? 0].map(image => (
|
{pages.length ? pages[(page - 1) ?? 0].map(image => (
|
||||||
<Grid item xs={12} sm={3} key={image.id}>
|
<div key={image.id}>
|
||||||
<ZiplineImage image={image} updateImages={updatePages} />
|
<ZiplineImage image={image} updateImages={() => updatePages(true)} />
|
||||||
</Grid>
|
</div>
|
||||||
)) : null}
|
)) : [1,2,3,4].map(x => (
|
||||||
</Grid>
|
<div key={x}>
|
||||||
|
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
{pages.length ? (
|
{pages.length ? (
|
||||||
<Box
|
<Box
|
||||||
display='flex'
|
sx={{
|
||||||
justifyContent='center'
|
display: 'flex',
|
||||||
alignItems='center'
|
justifyContent: 'center',
|
||||||
pt={2}
|
alignItems: 'center',
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 3,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Pagination count={pages.length} page={page} onChange={(_, v) => setPage(v)}/>
|
<Pagination total={pages.length} page={page} onChange={setPage}/>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,88 +1,32 @@
|
|||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { TextField, Button, Box, Typography, Select, MenuItem } from '@material-ui/core';
|
|
||||||
import Download from '@material-ui/icons/Download';
|
|
||||||
|
|
||||||
import { useFormik } from 'formik';
|
|
||||||
import * as yup from 'yup';
|
|
||||||
import useFetch from 'hooks/useFetch';
|
import useFetch from 'hooks/useFetch';
|
||||||
import Backdrop from 'components/Backdrop';
|
import Link from 'components/Link';
|
||||||
import Alert from 'components/Alert';
|
|
||||||
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
|
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
|
||||||
import { updateUser } from 'lib/redux/reducers/user';
|
import { updateUser } from 'lib/redux/reducers/user';
|
||||||
import { useRouter } from 'next/router';
|
import { useForm } from '@mantine/hooks';
|
||||||
|
import { Tooltip, TextInput, Button, Text, Title, Group, ColorInput } from '@mantine/core';
|
||||||
|
import { DownloadIcon } from '@modulz/radix-icons';
|
||||||
|
|
||||||
const validationSchema = yup.object({
|
function VarsTooltip({ children }) {
|
||||||
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 }) {
|
|
||||||
return (
|
return (
|
||||||
<TextField
|
<Tooltip position='top' placement='center' color='' label={
|
||||||
id={id}
|
<>
|
||||||
name={id}
|
<Text><b>{'{image.file}'}</b> - file name</Text>
|
||||||
label={label}
|
<Text><b>{'{image.mimetype}'}</b> - mimetype</Text>
|
||||||
value={formik.values[id]}
|
<Text><b>{'{image.id}'}</b> - id of the image</Text>
|
||||||
onChange={formik.handleChange}
|
<Text><b>{'{user.name}'}</b> - your username</Text>
|
||||||
error={formik.touched[id] && Boolean(formik.errors[id])}
|
visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables
|
||||||
helperText={formik.touched[id] && formik.errors[id]}
|
</>
|
||||||
variant='standard'
|
}>
|
||||||
fullWidth
|
{children}
|
||||||
sx={{ pb: 0.5 }}
|
</Tooltip>
|
||||||
{...other}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Manage() {
|
export default function Manage() {
|
||||||
const user = useStoreSelector(state => state.user);
|
const user = useStoreSelector(state => state.user);
|
||||||
const dispatch = useStoreDispatch();
|
const dispatch = useStoreDispatch();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [severity, setSeverity] = useState('success');
|
|
||||||
const [message, setMessage] = useState('Saved');
|
|
||||||
|
|
||||||
const genShareX = (withEmbed: boolean = false, withZws: boolean = false) => {
|
const genShareX = (withEmbed: boolean = false, withZws: boolean = false) => {
|
||||||
const config = {
|
const config = {
|
||||||
@@ -94,11 +38,11 @@ export default function Manage() {
|
|||||||
Headers: {
|
Headers: {
|
||||||
Authorization: user?.token,
|
Authorization: user?.token,
|
||||||
...(withEmbed && {Embed: 'true'}),
|
...(withEmbed && {Embed: 'true'}),
|
||||||
...(withZws && {ZWS: 'true'})
|
...(withZws && {ZWS: 'true'}),
|
||||||
},
|
},
|
||||||
URL: '$json:url$',
|
URL: '$json:files[0]$',
|
||||||
Body: 'MultipartFormData',
|
Body: 'MultipartFormData',
|
||||||
FileFormName: 'file'
|
FileFormName: 'file',
|
||||||
};
|
};
|
||||||
|
|
||||||
const pseudoElement = document.createElement('a');
|
const pseudoElement = document.createElement('a');
|
||||||
@@ -110,143 +54,66 @@ export default function Manage() {
|
|||||||
pseudoElement.parentNode.removeChild(pseudoElement);
|
pseudoElement.parentNode.removeChild(pseudoElement);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formik = useFormik({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
username: user.username,
|
username: user.username,
|
||||||
password: '',
|
password: '',
|
||||||
embedTitle: user.embedTitle ?? '',
|
embedTitle: user.embedTitle ?? '',
|
||||||
embedColor: user.embedColor
|
embedColor: user.embedColor,
|
||||||
|
embedSiteName: user.embedSiteName ?? '',
|
||||||
},
|
},
|
||||||
validationSchema,
|
});
|
||||||
onSubmit: async values => {
|
|
||||||
|
const onSubmit = async values => {
|
||||||
const cleanUsername = values.username.trim();
|
const cleanUsername = values.username.trim();
|
||||||
const cleanPassword = values.password.trim();
|
const cleanPassword = values.password.trim();
|
||||||
const cleanEmbedTitle = values.embedTitle.trim();
|
const cleanEmbedTitle = values.embedTitle.trim();
|
||||||
const cleanEmbedColor = values.embedColor.trim();
|
const cleanEmbedColor = values.embedColor.trim();
|
||||||
|
const cleanEmbedSiteName = values.embedSiteName.trim();
|
||||||
|
|
||||||
if (cleanUsername === '') return formik.setFieldError('username', 'Username can\'t be nothing');
|
if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
username: cleanUsername,
|
username: cleanUsername,
|
||||||
password: cleanPassword === '' ? null : cleanPassword,
|
password: cleanPassword === '' ? null : cleanPassword,
|
||||||
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
|
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
|
||||||
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor
|
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor,
|
||||||
|
embedSiteName: cleanEmbedSiteName === '' ? null : cleanEmbedSiteName,
|
||||||
};
|
};
|
||||||
|
|
||||||
const newUser = await useFetch('/api/user', 'PATCH', data);
|
const newUser = await useFetch('/api/user', 'PATCH', data);
|
||||||
|
|
||||||
if (newUser.error) {
|
if (newUser.error) {
|
||||||
setLoading(false);
|
|
||||||
setMessage('An error occured');
|
|
||||||
setSeverity('error');
|
|
||||||
setOpen(true);
|
|
||||||
} else {
|
} else {
|
||||||
dispatch(updateUser(newUser));
|
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 newUser = await useFetch('/api/user', 'PATCH', { customTheme: values });
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Backdrop open={loading}/>
|
<Title>Manage User</Title>
|
||||||
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
|
<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>
|
||||||
<Typography variant='h4' pb={2}>Manage User</Typography>
|
</VarsTooltip>
|
||||||
<form onSubmit={formik.handleSubmit}>
|
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||||
<TextInput id='username' label='Username' formik={formik} />
|
<TextInput id='username' label='Username' {...form.getInputProps('username')} />
|
||||||
<TextInput id='password' label='Password' formik={formik} type='password' />
|
<TextInput id='password' label='Password'type='password' {...form.getInputProps('password')} />
|
||||||
<TextInput id='embedTitle' label='Embed Title' formik={formik} />
|
<TextInput id='embedTitle' label='Embed Title' {...form.getInputProps('embedTitle')} />
|
||||||
<TextInput id='embedColor' label='Embed Color' formik={formik} />
|
<ColorInput id='embedColor' label='Embed Color' {...form.getInputProps('embedColor')} />
|
||||||
<Box
|
<TextInput id='embedSiteName' label='Embed Site Name' {...form.getInputProps('embedSiteName')} />
|
||||||
display='flex'
|
<Group position='right' sx={{ paddingTop: 12 }}>
|
||||||
justifyContent='right'
|
|
||||||
alignItems='right'
|
|
||||||
pt={2}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
variant='contained'
|
|
||||||
type='submit'
|
type='submit'
|
||||||
>Save User</Button>
|
>Save User</Button>
|
||||||
</Box>
|
</Group>
|
||||||
</form>
|
</form>
|
||||||
<Typography variant='h4' py={2}>Manage Theme</Typography>
|
|
||||||
<form onSubmit={customThemeFormik.handleSubmit}>
|
<Title sx={{ paddingTop: 12, paddingBottom: 12 }}>ShareX Config</Title>
|
||||||
<Select
|
<Group>
|
||||||
id='type'
|
<Button onClick={() => genShareX(false)} rightIcon={<DownloadIcon />}>ShareX Config</Button>
|
||||||
name='type'
|
<Button onClick={() => genShareX(true)} rightIcon={<DownloadIcon />}>ShareX Config with Embed</Button>
|
||||||
label='Type'
|
<Button onClick={() => genShareX(false, true)} rightIcon={<DownloadIcon />}>ShareX Config with ZWS</Button>
|
||||||
value={customThemeFormik.values['type']}
|
</Group>
|
||||||
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>
|
|
||||||
</form>
|
|
||||||
<Typography variant='h4' py={2}>ShareX Config</Typography>
|
|
||||||
<Button variant='contained' onClick={() => genShareX(false)} startIcon={<Download />}>ShareX Config</Button>
|
|
||||||
<Button variant='contained' sx={{ marginLeft: 1 }} onClick={() => genShareX(true)} startIcon={<Download />}>ShareX Config with Embed</Button>
|
|
||||||
<Button variant='contained' sx={{ marginLeft: 1 }} onClick={() => genShareX(false, true)} startIcon={<Download />}>ShareX Config with ZWS</Button>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
118
src/components/pages/Stats.tsx
Normal file
118
src/components/pages/Stats.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import Card from 'components/Card';
|
||||||
|
import Image from 'components/Image';
|
||||||
|
import ImagesTable from 'components/ImagesTable';
|
||||||
|
import useFetch from 'lib/hooks/useFetch';
|
||||||
|
import { useStoreSelector } from 'lib/redux/store';
|
||||||
|
import { Box, Text, Table, 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';
|
||||||
|
|
||||||
|
type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify';
|
||||||
|
|
||||||
|
export function bytesToRead(bytes: number) {
|
||||||
|
if (isNaN(bytes)) return '0.0 B';
|
||||||
|
if (bytes === Infinity) return '0.0 B';
|
||||||
|
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
let num = 0;
|
||||||
|
|
||||||
|
while (bytes > 1024) {
|
||||||
|
bytes /= 1024;
|
||||||
|
++num;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatText({ children }) {
|
||||||
|
return <Text color='gray' size='xl'>{children}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Dashboard() {
|
||||||
|
const user = useStoreSelector(state => state.user);
|
||||||
|
|
||||||
|
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,96 +1,120 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, 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 Backdrop from 'components/Backdrop';
|
|
||||||
import Alert from 'components/Alert';
|
|
||||||
import { useStoreSelector } from 'lib/redux/store';
|
import { useStoreSelector } from 'lib/redux/store';
|
||||||
import CenteredBox from 'components/CenteredBox';
|
|
||||||
import copy from 'copy-to-clipboard';
|
|
||||||
import Link from 'components/Link';
|
import Link from 'components/Link';
|
||||||
|
import { Button, Group, Text, useMantineTheme } from '@mantine/core';
|
||||||
|
import { ImageIcon, UploadIcon, CrossCircledIcon } from '@modulz/radix-icons';
|
||||||
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
|
import { useNotifications } from '@mantine/notifications';
|
||||||
|
import { useClipboard } from '@mantine/hooks';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Upload({ route }) {
|
export default function Upload({ route }) {
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
const notif = useNotifications();
|
||||||
|
const clipboard = useClipboard();
|
||||||
const user = useStoreSelector(state => state.user);
|
const user = useStoreSelector(state => state.user);
|
||||||
|
|
||||||
const [files, setFiles] = useState([]);
|
const [files, setFiles] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [open, setOpen] = useState(false);
|
useEffect(() => {
|
||||||
const [severity, setSeverity] = useState('success');
|
window.addEventListener('paste', (e: ClipboardEvent) => {
|
||||||
const [message, setMessage] = useState('Saved');
|
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',
|
||||||
|
message: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const handleUpload = async () => {
|
const handleUpload = async () => {
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
|
|
||||||
for (let i = 0; i !== files.length; ++i) body.append('file', files[i]);
|
for (let i = 0; i !== files.length; ++i) body.append('file', files[i]);
|
||||||
|
|
||||||
setLoading(true);
|
const id = notif.showNotification({
|
||||||
|
title: 'Uploading Images...',
|
||||||
|
message: '',
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
|
||||||
const res = await fetch('/api/upload', {
|
const res = await fetch('/api/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': user.token
|
'Authorization': user.token,
|
||||||
},
|
},
|
||||||
body
|
body,
|
||||||
});
|
});
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (res.ok && json.error === undefined) {
|
if (res.ok && json.error === undefined) {
|
||||||
setOpen(true);
|
notif.updateNotification(id, {
|
||||||
setSeverity('success');
|
title: 'Upload Successful',
|
||||||
|
message: <>Copied first image to clipboard! <br/>{json.files.map(x => (<Link key={x} href={x}>{x}<br/></Link>))}</>,
|
||||||
//@ts-ignore
|
color: 'green',
|
||||||
setMessage(<>Copied first image to clipboard! <br/>{json.files.map(x => (<Link key={x} href={x}>{x}<br/></Link>))}</>);
|
icon: <UploadIcon />,
|
||||||
copy(json.url);
|
});
|
||||||
|
clipboard.copy(json.url);
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
} else {
|
} else {
|
||||||
setOpen(true);
|
notif.updateNotification(id, {
|
||||||
setSeverity('error');
|
title: 'Upload Failed',
|
||||||
setMessage('Could not upload file: ' + json.error);
|
message: json.error,
|
||||||
|
color: 'red',
|
||||||
|
icon: <CrossCircledIcon />,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Backdrop open={loading}/>
|
<Dropzone
|
||||||
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
|
onDrop={(f) => setFiles([...files, ...f])}
|
||||||
|
|
||||||
<Typography variant='h4' pb={2}>Upload file</Typography>
|
|
||||||
<Dropzone onDrop={acceptedFiles => setFiles([...files, ...acceptedFiles])}>
|
|
||||||
{({getRootProps, getInputProps}) => (
|
|
||||||
<CardActionArea>
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
variant='outlined'
|
|
||||||
sx={{
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
display: 'block',
|
|
||||||
p: 5
|
|
||||||
}}
|
|
||||||
{...getRootProps()}
|
|
||||||
>
|
>
|
||||||
<input {...getInputProps()} />
|
{(status) => (
|
||||||
<CenteredBox><UploadIcon sx={{ fontSize: 100 }} /></CenteredBox>
|
<>
|
||||||
<CenteredBox><Typography variant='h5'>Drag an image or click to upload an image.</Typography></CenteredBox>
|
<Group position='center' spacing='xl' style={{ minHeight: 220, pointerEvents: 'none' }}>
|
||||||
{files.map(file => (
|
<ImageUploadIcon
|
||||||
<CenteredBox key={file.name}><Typography variant='h6'>{file.name}</Typography></CenteredBox>
|
status={status}
|
||||||
))}
|
style={{ width: 80, height: 80, color: getIconColor(status, theme) }}
|
||||||
</Paper>
|
/>
|
||||||
</CardActionArea>
|
|
||||||
|
<div>
|
||||||
|
<Text size='xl' inline>
|
||||||
|
Drag images here or click to select files
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Group position='center' spacing='xl' style={{ pointerEvents: 'none' }}>
|
||||||
|
{files.map(file => (<Text key={file.name} weight='bold'>{file.name}</Text>))}
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Dropzone>
|
</Dropzone>
|
||||||
|
<Group position='right'>
|
||||||
<Box
|
<Button leftIcon={<UploadIcon />} mt={12} onClick={handleUpload}>Upload</Button>
|
||||||
display='flex'
|
</Group>
|
||||||
justifyContent='right'
|
|
||||||
alignItems='right'
|
|
||||||
pt={2}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant='contained'
|
|
||||||
onClick={handleUpload}
|
|
||||||
>Upload</Button>
|
|
||||||
</Box>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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,115 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { useStoreSelector } from 'lib/redux/store';
|
||||||
import Backdrop from 'components/Backdrop';
|
|
||||||
import Alert from 'components/Alert';
|
|
||||||
import useFetch from 'hooks/useFetch';
|
import useFetch from 'hooks/useFetch';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useFormik } from 'formik';
|
import { useForm } from '@mantine/hooks';
|
||||||
|
import { Avatar, Modal, Title, TextInput, Group, Button, Card, Grid, ActionIcon, SimpleGrid, Switch, Skeleton } from '@mantine/core';
|
||||||
|
import { Cross1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
|
||||||
|
import { useNotifications } from '@mantine/notifications';
|
||||||
|
|
||||||
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 }) {
|
function CreateUserModal({ open, setOpen, updateUsers }) {
|
||||||
return (
|
const form = useForm({
|
||||||
<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({
|
|
||||||
initialValues: {
|
initialValues: {
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
administrator: false
|
administrator: false,
|
||||||
},
|
},
|
||||||
onSubmit: async (values) => {
|
});
|
||||||
|
const notif = useNotifications();
|
||||||
|
|
||||||
|
const onSubmit = async (values) => {
|
||||||
const cleanUsername = values.username.trim();
|
const cleanUsername = values.username.trim();
|
||||||
const cleanPassword = values.password.trim();
|
const cleanPassword = values.password.trim();
|
||||||
if (cleanUsername === '') return formik.setFieldError('username', 'Username can\'t be nothing');
|
if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
|
||||||
if (cleanPassword === '') return formik.setFieldError('password', 'Password can\'t be nothing');
|
if (cleanPassword === '') return form.setFieldError('password', 'Password can\'t be nothing');
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
username: cleanUsername,
|
username: cleanUsername,
|
||||||
password: cleanPassword,
|
password: cleanPassword,
|
||||||
administrator: values.administrator
|
administrator: values.administrator,
|
||||||
};
|
};
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setLoading(true);
|
|
||||||
const res = await useFetch('/api/auth/create', 'POST', data);
|
const res = await useFetch('/api/auth/create', 'POST', data);
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
setSeverity('error');
|
notif.showNotification({
|
||||||
setMessage('Could\'nt create user: ' + res.error);
|
title: 'Failed to create user',
|
||||||
setAlertOpen(true);
|
message: res.error,
|
||||||
} else {
|
icon: <TrashIcon />,
|
||||||
setSeverity('success');
|
color: 'red',
|
||||||
setMessage('Created user ' + res.username);
|
|
||||||
setAlertOpen(true);
|
|
||||||
updateUsers();
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
notif.showNotification({
|
||||||
|
title: 'Created user: ' + cleanUsername,
|
||||||
|
message: '',
|
||||||
|
icon: <PlusIcon />,
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUsers();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Modal
|
||||||
<Dialog
|
opened={open}
|
||||||
open={open}
|
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
PaperProps={{
|
title={<Title>Create User</Title>}
|
||||||
elevation: 1
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<DialogTitle>
|
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||||
Create User
|
<TextInput id='username' label='Username' {...form.getInputProps('username')} />
|
||||||
</DialogTitle>
|
<TextInput id='password' label='Password' type='password' {...form.getInputProps('password')} />
|
||||||
<form onSubmit={formik.handleSubmit}>
|
<Switch mt={12} id='administrator' label='Administrator' {...form.getInputProps('administrator')} />
|
||||||
<DialogContent>
|
|
||||||
<TextInput id='username' label='Username' formik={formik} />
|
<Group position='right' mt={22}>
|
||||||
<TextInput id='password' label='Password' formik={formik} type='password' />
|
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
<FormControlLabel
|
<Button type='submit'>Create</Button>
|
||||||
id='administrator'
|
</Group>
|
||||||
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>
|
</form>
|
||||||
</Dialog>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Users() {
|
export default function Users() {
|
||||||
const user = useStoreSelector(state => state.user);
|
const user = useStoreSelector(state => state.user);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const notif = useNotifications();
|
||||||
|
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [open, setOpen] = useState(false);
|
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 updateUsers = async () => {
|
const updateUsers = async () => {
|
||||||
setLoading(true);
|
|
||||||
const us = await useFetch('/api/users');
|
const us = await useFetch('/api/users');
|
||||||
if (!us.error) {
|
if (!us.error) {
|
||||||
setUsers(us);
|
setUsers(us);
|
||||||
} else {
|
} else {
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
};
|
};
|
||||||
setLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (user) => {
|
const handleDelete = async (user) => {
|
||||||
const res = await useFetch('/api/users', 'DELETE', {
|
const res = await useFetch('/api/users', 'DELETE', {
|
||||||
id: user.id
|
id: user.id,
|
||||||
});
|
});
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
setMessage(`Could not delete ${user.username}`);
|
notif.showNotification({
|
||||||
setSeverity('error');
|
title: 'Failed to delete user',
|
||||||
setOpen(true);
|
message: res.error,
|
||||||
|
color: 'red',
|
||||||
|
icon: <Cross1Icon />,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setMessage(`Deleted user ${res.username}`);
|
notif.showNotification({
|
||||||
setSeverity('success');
|
title: 'User deleted',
|
||||||
setOpen(true);
|
message: '',
|
||||||
updateUsers();
|
color: 'green',
|
||||||
|
icon: <TrashIcon />,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateUsers();
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -171,17 +118,38 @@ export default function Users() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Backdrop open={loading}/>
|
<CreateUserModal open={open} setOpen={setOpen} updateUsers={updateUsers} />
|
||||||
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
|
<Group>
|
||||||
<CreateUserDialog open={createOpen} setOpen={setCreateOpen} setSeverity={setSeverity} setMessage={setMessage} setLoading={setLoading} updateUsers={updateUsers} setAlertOpen={setOpen} />
|
<Title sx={{ marginBottom: 12 }}>Users</Title>
|
||||||
<Typography variant='h4' pb={2}>Users <IconButton onClick={() => setCreateOpen(true)}><AddIcon /></IconButton></Typography>
|
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon/></ActionIcon>
|
||||||
<Grid container spacing={2}>
|
</Group>
|
||||||
{users.filter(x => x.username !== user.username).map((user, i) => (
|
<SimpleGrid
|
||||||
<Grid item xs={12} sm={3} key={i}>
|
cols={3}
|
||||||
<Card user={user} handleDelete={handleDelete}/>
|
spacing='lg'
|
||||||
</Grid>
|
breakpoints={[
|
||||||
|
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{users.length ? users.filter(x => x.username !== user.username).map((user, i) => (
|
||||||
|
<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={() => handleDelete(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,7 @@
|
|||||||
import type { Config } from './types';
|
import type { Config } from './types';
|
||||||
import readConfig from './readConfig';
|
import readConfig from './readConfig';
|
||||||
|
import validateConfig from '../../server/validateConfig';
|
||||||
|
|
||||||
if (!global.config) global.config = readConfig() as Config;
|
if (!global.config) global.config = validateConfig(readConfig()) as unknown as Config;
|
||||||
|
|
||||||
export default global.config;
|
export default global.config;
|
||||||
@@ -5,7 +5,7 @@ export default async function useFetch(url: string, method: 'GET' | 'POST' | 'PA
|
|||||||
const res = await global.fetch(url, {
|
const res = await global.fetch(url, {
|
||||||
body: body ? JSON.stringify(body) : null,
|
body: body ? JSON.stringify(body) : null,
|
||||||
method,
|
method,
|
||||||
headers
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|||||||
@@ -14,21 +14,17 @@ export default function login() {
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await useFetch('/api/user');
|
|
||||||
|
|
||||||
if (res.error) return router.push('/auth/login');
|
const res = await useFetch('/api/user');
|
||||||
|
if (res.error) return router.push('/auth/login?url=' + router.route);
|
||||||
|
|
||||||
dispatch(updateUser(res));
|
dispatch(updateUser(res));
|
||||||
|
|
||||||
setUser(res);
|
setUser(res);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && user) {
|
if (!loading && user) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
load();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const { format } = require('fecha');
|
const { format } = require('fecha');
|
||||||
const { yellow, blueBright, magenta, red, cyan } = require('colorette');
|
const { blueBright, red, cyan } = require('colorette');
|
||||||
|
|
||||||
class Logger {
|
module.exports = class Logger {
|
||||||
static get(clas) {
|
static get(clas) {
|
||||||
if (typeof clas !== 'function') if (typeof clas !== 'string') throw new Error('not string/function');
|
if (typeof clas !== 'function') if (typeof clas !== 'string') throw new Error('not string/function');
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ class Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
error(error) {
|
error(error) {
|
||||||
console.log(this.formatMessage('ERROR', this.name, error.toString()));
|
console.log(this.formatMessage('ERROR', this.name, error.stack ?? error));
|
||||||
}
|
}
|
||||||
|
|
||||||
formatMessage(level, name, message) {
|
formatMessage(level, name, message) {
|
||||||
@@ -31,14 +31,8 @@ class Logger {
|
|||||||
switch (level) {
|
switch (level) {
|
||||||
case 'INFO':
|
case 'INFO':
|
||||||
return cyan('INFO ');
|
return cyan('INFO ');
|
||||||
case 'DEBUG':
|
|
||||||
return yellow('DEBUG');
|
|
||||||
case 'WARN':
|
|
||||||
return magenta('WARN ');
|
|
||||||
case 'ERROR':
|
case 'ERROR':
|
||||||
return red('ERROR');
|
return red('ERROR');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
module.exports = Logger;
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
import type { CookieSerializeOptions } from 'cookie';
|
import type { CookieSerializeOptions } from 'cookie';
|
||||||
import type { Image, Theme, User } from '@prisma/client';
|
|
||||||
|
|
||||||
import { serialize } from 'cookie';
|
import { serialize } from 'cookie';
|
||||||
import { sign64, unsign64 } from '../util';
|
import { sign64, unsign64 } from '../util';
|
||||||
@@ -23,7 +22,6 @@ export type NextApiReq = NextApiRequest & {
|
|||||||
embedTitle: string;
|
embedTitle: string;
|
||||||
embedColor: string;
|
embedColor: string;
|
||||||
systemTheme: string;
|
systemTheme: string;
|
||||||
customTheme?: Theme;
|
|
||||||
administrator: boolean;
|
administrator: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -38,14 +36,19 @@ export type NextApiRes = NextApiResponse & {
|
|||||||
forbid: (message: string) => void;
|
forbid: (message: string) => void;
|
||||||
bad: (message: string) => void;
|
bad: (message: string) => void;
|
||||||
json: (json: any) => void;
|
json: (json: any) => void;
|
||||||
|
ratelimited: () => void;
|
||||||
setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void;
|
setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse) => unknown) => (req: NextApiReq, res: NextApiRes) => {
|
export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse) => unknown) => (req: NextApiReq, res: NextApiRes) => {
|
||||||
res.error = (message: string) => {
|
|
||||||
res.setHeader('Content-Type', 'application/json');
|
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.json({
|
res.json({
|
||||||
error: message
|
error: message,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,21 +56,26 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
|||||||
res.setHeader('Content-Type', 'application/json');
|
res.setHeader('Content-Type', 'application/json');
|
||||||
res.status(403);
|
res.status(403);
|
||||||
res.json({
|
res.json({
|
||||||
error: '403: ' + message
|
error: '403: ' + message,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
res.bad = (message: string) => {
|
res.bad = (message: string) => {
|
||||||
res.setHeader('Content-Type', 'application/json');
|
|
||||||
res.status(401);
|
res.status(401);
|
||||||
res.json({
|
res.json({
|
||||||
error: '403: ' + message
|
error: '403: ' + message,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
res.ratelimited = () => {
|
||||||
|
res.status(429);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
error: '429: ratelimited',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json = (json: any) => {
|
res.json = (json: any) => {
|
||||||
res.setHeader('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
res.end(JSON.stringify(json));
|
res.end(JSON.stringify(json));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,7 +90,7 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
|||||||
res.setHeader('Set-Cookie', serialize(name, '', {
|
res.setHeader('Set-Cookie', serialize(name, '', {
|
||||||
path: '/',
|
path: '/',
|
||||||
expires: new Date(1),
|
expires: new Date(1),
|
||||||
maxAge: undefined
|
maxAge: undefined,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
req.user = async () => {
|
req.user = async () => {
|
||||||
@@ -92,7 +100,7 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
|||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: Number(userId)
|
id: Number(userId),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
administrator: true,
|
administrator: true,
|
||||||
@@ -101,10 +109,9 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
|||||||
id: true,
|
id: true,
|
||||||
password: true,
|
password: true,
|
||||||
systemTheme: true,
|
systemTheme: true,
|
||||||
customTheme: true,
|
|
||||||
token: true,
|
token: true,
|
||||||
username: true
|
username: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
@@ -130,7 +137,7 @@ export const setCookie = (
|
|||||||
) => {
|
) => {
|
||||||
|
|
||||||
if ('maxAge' in options) {
|
if ('maxAge' in options) {
|
||||||
options.expires = new Date(Date.now() + options.maxAge);
|
options.expires = new Date(Date.now() + options.maxAge * 1000);
|
||||||
options.maxAge /= 1000;
|
options.maxAge /= 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
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;
|
export default global.prisma;
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
const { existsSync, readFileSync } = require('fs');
|
const { existsSync, readFileSync } = require('fs');
|
||||||
const { join } = require('path');
|
const { join } = require('path');
|
||||||
const Logger = require('./logger');
|
const parse = require('@iarna/toml/parse-string.js');
|
||||||
|
const Logger = require('./logger.js');
|
||||||
|
|
||||||
const e = (val, type, fn, required = true) => ({ val, type, fn, required });
|
const e = (val, type, fn) => ({ val, type, fn });
|
||||||
|
|
||||||
const envValues = [
|
const envValues = [
|
||||||
e('SECURE', 'boolean', (c, v) => c.core.secure = v),
|
e('SECURE', 'boolean', (c, v) => c.core.secure = v),
|
||||||
@@ -10,22 +11,33 @@ const envValues = [
|
|||||||
e('HOST', 'string', (c, v) => c.core.host = v),
|
e('HOST', 'string', (c, v) => c.core.host = v),
|
||||||
e('PORT', 'number', (c, v) => c.core.port = v),
|
e('PORT', 'number', (c, v) => c.core.port = v),
|
||||||
e('DATABASE_URL', 'string', (c, v) => c.core.database_url = 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('UPLOADER_ROUTE', 'string', (c, v) => c.uploader.route = v),
|
e('UPLOADER_ROUTE', 'string', (c, v) => c.uploader.route = v),
|
||||||
e('UPLOADER_LENGTH', 'number', (c, v) => c.uploader.length = v),
|
e('UPLOADER_LENGTH', 'number', (c, v) => c.uploader.length = v),
|
||||||
e('UPLOADER_DIRECTORY', 'string', (c, v) => c.uploader.directory = v),
|
e('UPLOADER_DIRECTORY', 'string', (c, v) => c.uploader.directory = v),
|
||||||
e('UPLOADER_ADMIN_LIMIT', 'number', (c, v) => c.uploader.admin_limit = 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_USER_LIMIT', 'number', (c, v) => c.uploader.user_limit = v),
|
||||||
e('UPLOADER_DISABLED_EXTS', 'array', (c, v) => v ? c.uploader.disabled_extentions = v : c.uploader.disabled_extentions = [], false),
|
e('UPLOADER_DISABLED_EXTS', 'array', (c, v) => v ? c.uploader.disabled_extentions = v : c.uploader.disabled_extentions = []),
|
||||||
|
|
||||||
|
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),
|
||||||
];
|
];
|
||||||
|
|
||||||
module.exports = () => {
|
module.exports = function readConfig() {
|
||||||
if (!existsSync(join(process.cwd(), 'config.toml'))) {
|
if (!existsSync(join(process.cwd(), 'config.toml'))) {
|
||||||
Logger.get('config').info('reading environment');
|
if (!process.env.ZIPLINE_DOCKER_BUILD) Logger.get('config').info('reading environment');
|
||||||
return tryReadEnv();
|
return tryReadEnv();
|
||||||
} else {
|
} else {
|
||||||
|
if (process.env.ZIPLINE_DOCKER_BUILD) return;
|
||||||
|
|
||||||
Logger.get('config').info('reading config file');
|
Logger.get('config').info('reading config file');
|
||||||
const str = readFileSync(join(process.cwd(), 'config.toml'), 'utf8');
|
const str = readFileSync(join(process.cwd(), 'config.toml'), 'utf8');
|
||||||
const parsed = require('@iarna/toml/parse-string')(str);
|
const parsed = parse(str);
|
||||||
|
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
@@ -39,6 +51,8 @@ function tryReadEnv() {
|
|||||||
host: undefined,
|
host: undefined,
|
||||||
port: undefined,
|
port: undefined,
|
||||||
database_url: undefined,
|
database_url: undefined,
|
||||||
|
logger: undefined,
|
||||||
|
stats_interval: undefined,
|
||||||
},
|
},
|
||||||
uploader: {
|
uploader: {
|
||||||
route: undefined,
|
route: undefined,
|
||||||
@@ -46,29 +60,32 @@ function tryReadEnv() {
|
|||||||
directory: undefined,
|
directory: undefined,
|
||||||
admin_limit: undefined,
|
admin_limit: undefined,
|
||||||
user_limit: undefined,
|
user_limit: undefined,
|
||||||
disabled_extentions: undefined
|
disabled_extentions: undefined,
|
||||||
}
|
},
|
||||||
|
urls: {
|
||||||
|
route: undefined,
|
||||||
|
length: undefined,
|
||||||
|
},
|
||||||
|
ratelimit: {
|
||||||
|
user: undefined,
|
||||||
|
admin: undefined,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0, L = envValues.length; i !== L; ++i) {
|
for (let i = 0, L = envValues.length; i !== L; ++i) {
|
||||||
const envValue = envValues[i];
|
const envValue = envValues[i];
|
||||||
let value = process.env[envValue.val];
|
let value = process.env[envValue.val];
|
||||||
|
|
||||||
if (envValue.required && !value) {
|
if (!value) {
|
||||||
Logger.get('config').error(`there is no config file or required environment variables (${envValue.val})... exiting...`);
|
envValues[i].fn(config, undefined);
|
||||||
|
} else {
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
envValues[i].fn(config, value);
|
envValues[i].fn(config, value);
|
||||||
if (envValue.required) {
|
|
||||||
if (envValue.type === 'number') value = parseToNumber(value);
|
if (envValue.type === 'number') value = parseToNumber(value);
|
||||||
else if (envValue.type === 'boolean') value = parseToBoolean(value);
|
else if (envValue.type === 'boolean') value = parseToBoolean(value);
|
||||||
else if (envValue.type === 'array') value = parseToArray(value);
|
else if (envValue.type === 'array') value = parseToArray(value);
|
||||||
envValues[i].fn(config, value);
|
envValues[i].fn(config, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Theme } from '@prisma/client';
|
|
||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
@@ -6,8 +5,8 @@ export interface User {
|
|||||||
token: string;
|
token: string;
|
||||||
embedTitle: string;
|
embedTitle: string;
|
||||||
embedColor: string;
|
embedColor: string;
|
||||||
|
embedSiteName: string;
|
||||||
systemTheme: string;
|
systemTheme: string;
|
||||||
customTheme?: Theme;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: User = null;
|
const initialState: User = null;
|
||||||
|
|||||||
@@ -3,15 +3,36 @@
|
|||||||
import createTheme from '.';
|
import createTheme from '.';
|
||||||
|
|
||||||
export default createTheme({
|
export default createTheme({
|
||||||
type: 'dark',
|
colorScheme: 'dark',
|
||||||
primary: '#E6B450',
|
primaryColor: 'orange',
|
||||||
secondary: '#FFEE99',
|
other: {
|
||||||
error: '#F07178',
|
AppShell_backgroundColor: '#0a0e14',
|
||||||
warning: '#F29668',
|
hover: '#191e29',
|
||||||
info: '#95E6CB',
|
},
|
||||||
border: '#191e29',
|
colors: {
|
||||||
background: {
|
dark: [
|
||||||
main: '#0A0E14',
|
'#ffffff',
|
||||||
paper: '#0D1016'
|
'#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 '.';
|
import createTheme from '.';
|
||||||
|
|
||||||
export default createTheme({
|
export default createTheme({
|
||||||
type: 'light',
|
colorScheme: 'light',
|
||||||
primary: '#FF9940',
|
primaryColor: 'orange',
|
||||||
secondary: '#E6BA7E',
|
other: {
|
||||||
error: '#F07171',
|
AppShell_backgroundColor: '#FAFAFA',
|
||||||
warning: '#ED9366',
|
hover: '#FAFAFA',
|
||||||
info: '#95E6CB',
|
},
|
||||||
border: '#e3e3e3',
|
colors: {
|
||||||
background: {
|
orange: [
|
||||||
main: '#FAFAFA',
|
'#FFFFFF',
|
||||||
paper: '#FFFFFF'
|
'#FCF6EA',
|
||||||
}
|
'#F9EDD4',
|
||||||
|
'#F3DAA8',
|
||||||
|
'#F2D69D',
|
||||||
|
'#F0D192',
|
||||||
|
'#EFCC87',
|
||||||
|
'#EDC77C',
|
||||||
|
'#EABE66',
|
||||||
|
'#E6B450',
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -3,15 +3,36 @@
|
|||||||
import createTheme from '.';
|
import createTheme from '.';
|
||||||
|
|
||||||
export default createTheme({
|
export default createTheme({
|
||||||
type: 'dark',
|
colorScheme: 'dark',
|
||||||
primary: '#FFCC66',
|
primaryColor: 'orange',
|
||||||
secondary: '#FFD580',
|
other: {
|
||||||
error: '#F28779',
|
AppShell_backgroundColor: '#1F2430',
|
||||||
warning: '#F29E74',
|
hover: '#2a2f3b',
|
||||||
info: '#95E6CB',
|
},
|
||||||
border: '#363c4d',
|
colors: {
|
||||||
background: {
|
dark: [
|
||||||
main: '#1F2430',
|
'#ffffff',
|
||||||
paper: '#232834'
|
'#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 '.';
|
import createTheme from '.';
|
||||||
|
|
||||||
export default createTheme({
|
export default createTheme({
|
||||||
type: 'dark',
|
colorScheme: 'dark',
|
||||||
primary: '#2c39a6',
|
primaryColor: 'blue',
|
||||||
secondary: '#7344e2',
|
other: {
|
||||||
error: '#ff4141',
|
AppShell_backgroundColor: '#000000',
|
||||||
warning: '#ff9800',
|
hover: '#2b2b2b',
|
||||||
info: '#2f6fb9',
|
},
|
||||||
border: '#2b2b2b',
|
colors: {
|
||||||
background: {
|
dark: [
|
||||||
main: '#000000',
|
'#ffffff',
|
||||||
paper: '#060606'
|
'#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 '.';
|
import createTheme from '.';
|
||||||
|
|
||||||
export default createTheme({
|
export default createTheme({
|
||||||
type: 'dark',
|
colorScheme: 'dark',
|
||||||
primary: '#2c39a6',
|
primaryColor: 'blue',
|
||||||
secondary: '#7344e2',
|
other: {
|
||||||
error: '#ff4141',
|
AppShell_backgroundColor: '#05070f',
|
||||||
warning: '#ff9800',
|
hover: '#181c28',
|
||||||
info: '#2f6fb9',
|
},
|
||||||
border: '#1b2541',
|
colors: {
|
||||||
background: {
|
dark: [
|
||||||
main: '#05070f',
|
'#FFFFFF',
|
||||||
paper: '#0c101c'
|
'#293747',
|
||||||
}
|
'#6C7A8D',
|
||||||
|
'#232F41',
|
||||||
|
'#41566e',
|
||||||
|
'#171F35',
|
||||||
|
'#181c28',
|
||||||
|
'#0c101c',
|
||||||
|
'#060824',
|
||||||
|
'#00001E',
|
||||||
|
],
|
||||||
|
blue: [
|
||||||
|
'#FFFFFF',
|
||||||
|
'#7C7DC2',
|
||||||
|
'#7778C0',
|
||||||
|
'#6C6FBC',
|
||||||
|
'#575DB5',
|
||||||
|
'#4D54B2',
|
||||||
|
'#424BAE',
|
||||||
|
'#3742AA',
|
||||||
|
'#323EA8',
|
||||||
|
'#2C39A6',
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -3,15 +3,36 @@
|
|||||||
import createTheme from '.';
|
import createTheme from '.';
|
||||||
|
|
||||||
export default createTheme({
|
export default createTheme({
|
||||||
type: 'dark',
|
colorScheme: 'dark',
|
||||||
primary: '#BD93F9',
|
primaryColor: 'violet',
|
||||||
secondary: '#6272A4',
|
other: {
|
||||||
error: '#FF5555',
|
AppShell_backgroundColor: '#282A36',
|
||||||
warning: '#FFB86C',
|
hover: '#4e5062',
|
||||||
info: '#8BE9FD',
|
},
|
||||||
border: '#7D8096',
|
colors: {
|
||||||
background: {
|
dark: [
|
||||||
main: '#282A36',
|
'#FFFFFF',
|
||||||
paper: '#44475A'
|
'#CED0D4',
|
||||||
}
|
'#E8E8EB',
|
||||||
|
'#D1D1D6',
|
||||||
|
'#BABAC2',
|
||||||
|
'#A2A3AD',
|
||||||
|
'#4e5062',
|
||||||
|
'#44475A',
|
||||||
|
'#5C5E6F',
|
||||||
|
'#44475A',
|
||||||
|
],
|
||||||
|
violet: [
|
||||||
|
'#FFFFFF',
|
||||||
|
'#F7F2FF',
|
||||||
|
'#EFE4FE',
|
||||||
|
'#EBDEFE',
|
||||||
|
'#E7D7FD',
|
||||||
|
'#DEC9FC',
|
||||||
|
'#D6BCFC',
|
||||||
|
'#CEAEFB',
|
||||||
|
'#C6A1FA',
|
||||||
|
'#BD93F9',
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -1,54 +1,5 @@
|
|||||||
import { createTheme as muiCreateTheme } from '@material-ui/core/styles';
|
import { MantineThemeOverride } from '@mantine/core';
|
||||||
|
|
||||||
export interface ThemeOptions {
|
export default function createTheme(o: MantineThemeOverride) {
|
||||||
type: 'dark' | 'light';
|
return o;
|
||||||
primary: string;
|
|
||||||
secondary: string;
|
|
||||||
error: string;
|
|
||||||
warning: string;
|
|
||||||
info: string;
|
|
||||||
border: string;
|
|
||||||
background: ThemeOptionsBackground;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ThemeOptionsBackground {
|
|
||||||
main: string;
|
|
||||||
paper: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function createTheme(o: ThemeOptions) {
|
|
||||||
return muiCreateTheme({
|
|
||||||
palette: {
|
|
||||||
mode: o.type,
|
|
||||||
primary: {
|
|
||||||
main: o.primary,
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
main: o.secondary,
|
|
||||||
},
|
|
||||||
background: {
|
|
||||||
default: o.background.main,
|
|
||||||
paper: o.background.paper,
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
main: o.error,
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
main: o.warning,
|
|
||||||
},
|
|
||||||
info: {
|
|
||||||
main: o.info,
|
|
||||||
},
|
|
||||||
divider: o.border,
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
MuiTableHead: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
backgroundColor: o.border
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
24
src/lib/themes/light_blue.ts
Normal file
24
src/lib/themes/light_blue.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import createTheme from '.';
|
||||||
|
|
||||||
|
export default createTheme({
|
||||||
|
colorScheme: 'light',
|
||||||
|
primaryColor: 'blue',
|
||||||
|
other: {
|
||||||
|
AppShell_backgroundColor: '#FAFAFA',
|
||||||
|
hover: '#FAFAFA',
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
blue: [
|
||||||
|
'#FFFFFF',
|
||||||
|
'#7C7DC2',
|
||||||
|
'#7778C0',
|
||||||
|
'#6C6FBC',
|
||||||
|
'#575DB5',
|
||||||
|
'#4D54B2',
|
||||||
|
'#424BAE',
|
||||||
|
'#3742AA',
|
||||||
|
'#323EA8',
|
||||||
|
'#2C39A6',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
36
src/lib/themes/matcha_dark_azul.ts
Normal file
36
src/lib/themes/matcha_dark_azul.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import createTheme from '.';
|
||||||
|
|
||||||
|
export default createTheme({
|
||||||
|
colorScheme: 'dark',
|
||||||
|
primaryColor: 'blue',
|
||||||
|
other: {
|
||||||
|
AppShell_backgroundColor: '#1b1d24',
|
||||||
|
hover: '#3c3f44',
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
dark: [
|
||||||
|
'#FFFFFF',
|
||||||
|
'#C8C8CA',
|
||||||
|
'#F5F5F5',
|
||||||
|
'#909194',
|
||||||
|
'#585A5F',
|
||||||
|
'#4A4D52',
|
||||||
|
'#3C3F44',
|
||||||
|
'#202329',
|
||||||
|
'#272A30',
|
||||||
|
'#202329',
|
||||||
|
],
|
||||||
|
blue: [
|
||||||
|
'#FFFFFF',
|
||||||
|
'#E6F3FB',
|
||||||
|
'#CDE6F6',
|
||||||
|
'#B4D9F2',
|
||||||
|
'#9ACCED',
|
||||||
|
'#8EC6EB',
|
||||||
|
'#81BFE9',
|
||||||
|
'#67B2E4',
|
||||||
|
'#4EA5E0',
|
||||||
|
'#3498DB',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,15 +3,36 @@
|
|||||||
import createTheme from '.';
|
import createTheme from '.';
|
||||||
|
|
||||||
export default createTheme({
|
export default createTheme({
|
||||||
type: 'dark',
|
colorScheme: 'dark',
|
||||||
primary: '#81A1C1',
|
primaryColor: 'blue',
|
||||||
secondary: '#88C0D0',
|
other: {
|
||||||
error: '#BF616A',
|
AppShell_backgroundColor: '#2E3440',
|
||||||
warning: '#EBCB8B',
|
hover: '#6c727e',
|
||||||
info: '#5E81AC',
|
},
|
||||||
border: '#565e70',
|
colors: {
|
||||||
background: {
|
dark: [
|
||||||
main: '#2E3440',
|
'#FFFFFF',
|
||||||
paper: '#3B4252'
|
'#CED0D4',
|
||||||
}
|
'#B6B9BF',
|
||||||
|
'#9DA1A9',
|
||||||
|
'#858A94',
|
||||||
|
'#6C727E',
|
||||||
|
'#606673',
|
||||||
|
'#3B4252',
|
||||||
|
'#484E5D',
|
||||||
|
'#3B4252',
|
||||||
|
],
|
||||||
|
blue: [
|
||||||
|
'#FFFFFF',
|
||||||
|
'#E0E8F0',
|
||||||
|
'#C0D0E0',
|
||||||
|
'#B9CBDD',
|
||||||
|
'#B1C5D9',
|
||||||
|
'#A1B9D1',
|
||||||
|
'#99B3CD',
|
||||||
|
'#91ADC9',
|
||||||
|
'#89A7C5',
|
||||||
|
'#81A1C1',
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
// https://github.com/AlphaNecron/
|
|
||||||
// https://github.com/arcticicestudio/nord
|
|
||||||
import createTheme from '.';
|
|
||||||
|
|
||||||
export default createTheme({
|
|
||||||
type: 'light',
|
|
||||||
primary: '#81A1C1',
|
|
||||||
secondary: '#88C0D0',
|
|
||||||
error: '#BF616A',
|
|
||||||
warning: '#EBCB8B',
|
|
||||||
info: '#5E81AC',
|
|
||||||
border: '#989fab',
|
|
||||||
background: {
|
|
||||||
main: '#D8DEE9',
|
|
||||||
paper: '#E5E9F0'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
37
src/lib/themes/qogir_dark.ts
Normal file
37
src/lib/themes/qogir_dark.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import createTheme from '.';
|
||||||
|
|
||||||
|
export default createTheme({
|
||||||
|
colorScheme: 'dark',
|
||||||
|
primaryColor: 'blue',
|
||||||
|
other: {
|
||||||
|
AppShell_backgroundColor: '#32343d',
|
||||||
|
hover: '#34363d',
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
dark: [
|
||||||
|
'#FFFFFF',
|
||||||
|
'#C9CACC',
|
||||||
|
'#F5F5F5',
|
||||||
|
'#78797E',
|
||||||
|
'#5D5E64',
|
||||||
|
'#42434A',
|
||||||
|
'#34363D',
|
||||||
|
'#262830',
|
||||||
|
'#2A2C34',
|
||||||
|
'#262830',
|
||||||
|
],
|
||||||
|
|
||||||
|
blue: [
|
||||||
|
'#FFFFFF',
|
||||||
|
'#E6F3FB',
|
||||||
|
'#CDE6F6',
|
||||||
|
'#B4D9F2',
|
||||||
|
'#9ACCED',
|
||||||
|
'#8EC6EB',
|
||||||
|
'#81BFE9',
|
||||||
|
'#67B2E4',
|
||||||
|
'#4EA5E0',
|
||||||
|
'#3498DB',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -13,6 +13,12 @@ export interface ConfigCore {
|
|||||||
|
|
||||||
// The PostgreSQL database url
|
// The PostgreSQL database url
|
||||||
database_url: string
|
database_url: string
|
||||||
|
|
||||||
|
// Whether or not to log stuff
|
||||||
|
logger: boolean;
|
||||||
|
|
||||||
|
// The interval to store stats
|
||||||
|
stats_interval: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigUploader {
|
export interface ConfigUploader {
|
||||||
@@ -35,7 +41,26 @@ export interface ConfigUploader {
|
|||||||
disabled_extentions: string[];
|
disabled_extentions: 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 {
|
export interface Config {
|
||||||
core: ConfigCore;
|
core: ConfigCore;
|
||||||
uploader: ConfigUploader;
|
uploader: ConfigUploader;
|
||||||
|
urls: ConfigUrls;
|
||||||
|
ratelimit: ConfigRatelimit;
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,8 @@ import { hash, verify } from 'argon2';
|
|||||||
import { readdir, stat } from 'fs/promises';
|
import { readdir, stat } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import prisma from './prisma';
|
import prisma from './prisma';
|
||||||
import { InvisibleImage } from '@prisma/client';
|
import { InvisibleImage, InvisibleUrl } from '@prisma/client';
|
||||||
|
import config from './config';
|
||||||
|
|
||||||
export async function hashPassword(s: string): Promise<string> {
|
export async function hashPassword(s: string): Promise<string> {
|
||||||
return await hash(s);
|
return await hash(s);
|
||||||
@@ -89,21 +90,21 @@ export function bytesToRead(bytes: number) {
|
|||||||
return `${bytes.toFixed(1)} ${units[num]}`;
|
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createInvisURL(length: number) {
|
export function randomInvis(length: number) {
|
||||||
// some parts from https://github.com/tycrek/ass/blob/master/generators/lengthGen.js
|
// some parts from https://github.com/tycrek/ass/blob/master/generators/lengthGen.js
|
||||||
const invisibleCharset = ['\u200B', '\u2060', '\u200C', '\u200D'];
|
const invisibleCharset = ['\u200B', '\u2060', '\u200C', '\u200D'];
|
||||||
|
|
||||||
return [...randomBytes(length)].map((byte) => invisibleCharset[Number(byte) % invisibleCharset.length]).join('').slice(1).concat(invisibleCharset[0]);
|
return [...randomBytes(length)].map((byte) => invisibleCharset[Number(byte) % invisibleCharset.length]).join('').slice(1).concat(invisibleCharset[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createInvis(length: number, imageId: number) {
|
export function createInvisImage(length: number, imageId: number) {
|
||||||
const retry = async (): Promise<InvisibleImage> => {
|
const retry = async (): Promise<InvisibleImage> => {
|
||||||
const invis = createInvisURL(length);
|
const invis = randomInvis(length);
|
||||||
|
|
||||||
const existing = await prisma.invisibleImage.findUnique({
|
const existing = await prisma.invisibleImage.findUnique({
|
||||||
where: {
|
where: {
|
||||||
invis
|
invis,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existing) return retry();
|
if (existing) return retry();
|
||||||
@@ -111,8 +112,8 @@ export function createInvis(length: number, imageId: number) {
|
|||||||
const inv = await prisma.invisibleImage.create({
|
const inv = await prisma.invisibleImage.create({
|
||||||
data: {
|
data: {
|
||||||
invis,
|
invis,
|
||||||
imageId
|
imageId,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return inv;
|
return inv;
|
||||||
@@ -120,3 +121,28 @@ export function createInvis(length: number, imageId: number) {
|
|||||||
|
|
||||||
return retry();
|
return retry();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createInvisURL(length: number, urlId: string) {
|
||||||
|
const retry = async (): Promise<InvisibleUrl> => {
|
||||||
|
const invis = randomInvis(length);
|
||||||
|
|
||||||
|
const existing = await prisma.invisibleUrl.findUnique({
|
||||||
|
where: {
|
||||||
|
invis,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) return retry();
|
||||||
|
|
||||||
|
const ur = await prisma.invisibleUrl.create({
|
||||||
|
data: {
|
||||||
|
invis,
|
||||||
|
urlId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return ur;
|
||||||
|
};
|
||||||
|
|
||||||
|
return retry();
|
||||||
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Typography } from '@material-ui/core';
|
import { Box, Text } from '@mantine/core';
|
||||||
|
|
||||||
export default function FourOhFour() {
|
export default function FourOhFour() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box
|
<Box
|
||||||
display='flex'
|
sx={{
|
||||||
justifyContent='center'
|
display: 'flex',
|
||||||
alignItems='center'
|
alignItems: 'center',
|
||||||
minHeight='100vh'
|
minHeight: '100vh',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant='h2'>404 - Not Found</Typography>
|
<Text size='xl'>404 - Not Found</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import { GetServerSideProps } from 'next';
|
import { GetServerSideProps } from 'next';
|
||||||
import { Box } from '@material-ui/core';
|
import { Box, useMantineTheme } from '@mantine/core';
|
||||||
import config from 'lib/config';
|
import config from 'lib/config';
|
||||||
import prisma from 'lib/prisma';
|
import prisma from 'lib/prisma';
|
||||||
import getFile from '../../server/static';
|
import { getFile } from '../../server/util';
|
||||||
|
import { parse } from 'lib/clientUtils';
|
||||||
|
import * as exts from '../../scripts/exts';
|
||||||
|
import { Prism } from '@mantine/prism';
|
||||||
|
import ZiplineTheming from 'components/Theming';
|
||||||
|
|
||||||
export default function EmbeddedImage({ image, title, username, color, normal, embed }) {
|
export default function EmbeddedImage({ image, user }) {
|
||||||
const dataURL = (route: string) => `${route}/${image.file}`;
|
const dataURL = (route: string) => `${route}/${image.file}`;
|
||||||
|
|
||||||
|
// reapply date from workaround
|
||||||
|
image.created_at = new Date(image.created_at);
|
||||||
|
|
||||||
const updateImage = () => {
|
const updateImage = () => {
|
||||||
const imageEl = document.getElementById('image_content') as HTMLImageElement;
|
const imageEl = document.getElementById('image_content') as HTMLImageElement;
|
||||||
|
|
||||||
@@ -19,24 +26,16 @@ export default function EmbeddedImage({ image, title, username, color, normal, e
|
|||||||
else imageEl.width = original.width;
|
else imageEl.width = original.width;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof window !== 'undefined') window.onresize = () => updateImage();
|
|
||||||
useEffect(() => updateImage(), []);
|
useEffect(() => updateImage(), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
{embed && (
|
{image.embed && (
|
||||||
<>
|
<>
|
||||||
{title ? (
|
{user.embedSiteName && (<meta property='og:site_name' content={parse(user.embedSiteName, image, user)} />)}
|
||||||
<>
|
{user.embedTitle && (<meta property='og:title' content={parse(user.embedTitle, image, user)} />)}
|
||||||
<meta property='og:site_name' content={`${image.file} • ${username}`} />
|
<meta property='theme-color' content={user.embedColor}/>
|
||||||
<meta property='og:title' content={title} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<meta property='og:title' content={`${image.file} • ${username}`} />
|
|
||||||
)}
|
|
||||||
<meta property='theme-color' content={color}/>
|
|
||||||
<meta property='og:url' content={dataURL(normal)} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<meta property='og:image' content={dataURL('/r')} />
|
<meta property='og:image' content={dataURL('/r')} />
|
||||||
@@ -44,10 +43,12 @@ export default function EmbeddedImage({ image, title, username, color, normal, e
|
|||||||
<title>{image.file}</title>
|
<title>{image.file}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<Box
|
<Box
|
||||||
display='flex'
|
sx={{
|
||||||
justifyContent='center'
|
display: 'flex',
|
||||||
alignItems='center'
|
alignItems: 'center',
|
||||||
minHeight='100vh'
|
minHeight: '100vh',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<img src={dataURL('/r')} alt={dataURL('/r')} id='image_content' />
|
<img src={dataURL('/r')} alt={dataURL('/r')} id='image_content' />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -58,16 +59,37 @@ export default function EmbeddedImage({ image, title, username, color, normal, e
|
|||||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||||
const id = context.params.id[1];
|
const id = context.params.id[1];
|
||||||
const route = context.params.id[0];
|
const route = context.params.id[0];
|
||||||
if (route !== config.uploader.route.substring(1)) return {
|
const routes = [config.uploader.route.substring(1), config.urls.route.substring(1)];
|
||||||
notFound: true
|
if (!routes.includes(route)) return { notFound: true };
|
||||||
|
if (route === routes[1]) {
|
||||||
|
const url = await prisma.url.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ id },
|
||||||
|
{ vanity: id },
|
||||||
|
{ invisible: { invis: id } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
destination: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!url) return { notFound: true };
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {},
|
||||||
|
redirect: {
|
||||||
|
destination: url.destination,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
} else {
|
||||||
const image = await prisma.image.findFirst({
|
const image = await prisma.image.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{ file: id },
|
{ file: id },
|
||||||
{ invisible: { invis: id } }
|
{ invisible: { invis: id } },
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
mimetype: true,
|
mimetype: true,
|
||||||
@@ -75,47 +97,51 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
|||||||
file: true,
|
file: true,
|
||||||
invisible: true,
|
invisible: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
embed: true
|
embed: true,
|
||||||
}
|
created_at: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!image) return { notFound: true };
|
if (!image) return { notFound: true };
|
||||||
|
|
||||||
if (!image.embed) {
|
|
||||||
const data = await getFile(config.uploader.directory, id);
|
|
||||||
if (!data) return { notFound: true };
|
|
||||||
|
|
||||||
context.res.end(data);
|
|
||||||
return { props: {} };
|
|
||||||
};
|
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
select: {
|
select: {
|
||||||
embedTitle: true,
|
embedTitle: true,
|
||||||
embedColor: true,
|
embedColor: true,
|
||||||
username: true
|
embedSiteName: true,
|
||||||
|
username: true,
|
||||||
|
id: true,
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
id: image.userId
|
id: image.userId,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
//@ts-ignore workaround because next wont allow date
|
||||||
|
image.created_at = image.created_at.toString();
|
||||||
|
|
||||||
|
const prismRender = Object.keys(exts).includes(image.file.split('.').pop());
|
||||||
|
// let prismRenderCode;/
|
||||||
|
// if (prismRender) prismRenderCode = (await getFile(config.uploader.directory, id)).toString();
|
||||||
|
if (prismRender) return {
|
||||||
|
redirect: {
|
||||||
|
destination: `/code/${image.file}`,
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
if (!image.mimetype.startsWith('image')) {
|
if (!image.mimetype.startsWith('image')) {
|
||||||
const data = await getFile(config.uploader.directory, id);
|
const data = await getFile(config.uploader.directory, id);
|
||||||
if (!data) return { notFound: true };
|
if (!data) return { notFound: true };
|
||||||
|
|
||||||
context.res.end(data);
|
context.res.end(data);
|
||||||
return { props: {} };
|
return { props: {} };
|
||||||
};
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
image,
|
image,
|
||||||
title: user.embedTitle,
|
user,
|
||||||
color: user.embedColor,
|
},
|
||||||
username: user.username,
|
|
||||||
normal: config.uploader.route,
|
|
||||||
embed: image.embed
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,33 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Theming from 'components/Theming';
|
|
||||||
import { useStore } from 'lib/redux/store';
|
import { useStore } from 'lib/redux/store';
|
||||||
|
import ZiplineTheming from 'components/Theming';
|
||||||
|
|
||||||
export default function MyApp({ Component, pageProps }) {
|
export default function MyApp({ Component, pageProps }) {
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const jssStyles = document.querySelector('#jss-server-side');
|
|
||||||
if (jssStyles) jssStyles.parentElement.removeChild(jssStyles);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{Component.title}</title>
|
<title>{Component.title}</title>
|
||||||
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width' />
|
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width' />
|
||||||
</Head>
|
</Head>
|
||||||
<Theming
|
<ZiplineTheming Component={Component} pageProps={pageProps} />
|
||||||
Component={Component}
|
|
||||||
pageProps={pageProps}
|
|
||||||
/>
|
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
MyApp.propTypes = {
|
|
||||||
Component: PropTypes.elementType.isRequired,
|
|
||||||
pageProps: PropTypes.object.isRequired,
|
|
||||||
};
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Document, { Html, Head, Main, NextScript } from 'next/document';
|
import Document, { Html, Head, Main, NextScript } from 'next/document';
|
||||||
|
import { createGetInitialProps } from '@mantine/next';
|
||||||
|
|
||||||
|
const getInitialProps = createGetInitialProps();
|
||||||
|
|
||||||
class MyDocument extends Document {
|
class MyDocument extends Document {
|
||||||
static async getInitialProps(ctx) {
|
static getInitialProps = getInitialProps;
|
||||||
const initialProps = await Document.getInitialProps(ctx);
|
|
||||||
return { ...initialProps };
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||||||
|
|
||||||
const existing = await prisma.user.findFirst({
|
const existing = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
username
|
username,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
if (existing) return res.forbid('user exists');
|
if (existing) return res.forbid('user exists');
|
||||||
|
|
||||||
@@ -29,8 +29,8 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||||||
password: hashed,
|
password: hashed,
|
||||||
username,
|
username,
|
||||||
token: createToken(),
|
token: createToken(),
|
||||||
administrator
|
administrator,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
delete newUser.password;
|
delete newUser.password;
|
||||||
|
|||||||
@@ -15,16 +15,16 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||||||
username: 'administrator',
|
username: 'administrator',
|
||||||
password: await hashPassword('password'),
|
password: await hashPassword('password'),
|
||||||
token: createToken(),
|
token: createToken(),
|
||||||
administrator: true
|
administrator: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
Logger.get('database').info('created default user:\nUsername: "administrator"\nPassword: "password"');
|
Logger.get('database').info('created default user:\nUsername: "administrator"\nPassword: "password"');
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
username
|
username,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) return res.status(404).end(JSON.stringify({ error: 'User not found' }));
|
if (!user) return res.status(404).end(JSON.stringify({ error: 'User not found' }));
|
||||||
@@ -32,8 +32,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||||||
const valid = await checkPassword(password, user.password);
|
const valid = await checkPassword(password, user.password);
|
||||||
if (!valid) return res.forbid('Wrong password');
|
if (!valid) return res.forbid('Wrong password');
|
||||||
|
|
||||||
// 604800 seconds is 1 week
|
res.setCookie('user', user.id, { sameSite: true, expires: new Date(Date.now() + (6.048e+8 * 2)), path: '/' });
|
||||||
res.setCookie('user', user.id, { sameSite: true, maxAge: 604800, path: '/' });
|
|
||||||
|
|
||||||
Logger.get('user').info(`User ${user.username} (${user.id}) logged in`);
|
Logger.get('user').info(`User ${user.username} (${user.id}) logged in`);
|
||||||
|
|
||||||
|
|||||||
39
src/pages/api/shorten.ts
Normal file
39
src/pages/api/shorten.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import prisma from 'lib/prisma';
|
||||||
|
import zconfig from 'lib/config';
|
||||||
|
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
|
||||||
|
import { createInvisURL, randomChars } from 'lib/util';
|
||||||
|
import Logger from 'lib/logger';
|
||||||
|
|
||||||
|
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
|
if (req.method !== 'POST') return res.forbid('no allow');
|
||||||
|
if (!req.headers.authorization) return res.forbid('no authorization');
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
token: req.headers.authorization,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) return res.forbid('authorization incorect');
|
||||||
|
if (!req.body) return res.error('no body');
|
||||||
|
if (!req.body.url) return res.error('no url');
|
||||||
|
const rand = randomChars(zconfig.urls.length);
|
||||||
|
|
||||||
|
let invis;
|
||||||
|
const url = await prisma.url.create({
|
||||||
|
data: {
|
||||||
|
id: rand,
|
||||||
|
vanity: req.body.vanity ?? null,
|
||||||
|
destination: req.body.url,
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.headers.zws) invis = await createInvisURL(zconfig.urls.length, url.id);
|
||||||
|
|
||||||
|
Logger.get('url').info(`User ${user.username} (${user.id}) shortenned a url ${url.destination} (${url.id})`);
|
||||||
|
|
||||||
|
return res.json({ url: `${zconfig.core.secure ? 'https' : 'http'}://${req.headers.host}${zconfig.urls.route}/${req.body.vanity ? req.body.vanity : invis ? invis.invis : url.id}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withZipline(handler);
|
||||||
@@ -1,62 +1,18 @@
|
|||||||
import { join } from 'path';
|
|
||||||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||||
import prisma from 'lib/prisma';
|
import prisma from 'lib/prisma';
|
||||||
import { bytesToRead, sizeOfDir } from 'lib/util';
|
|
||||||
import config from 'lib/config';
|
|
||||||
|
|
||||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
const user = await req.user();
|
const user = await req.user();
|
||||||
if (!user) return res.forbid('not logged in');
|
if (!user) return res.forbid('not logged in');
|
||||||
|
|
||||||
const size = await sizeOfDir(join(process.cwd(), config.uploader.directory));
|
const stats = await prisma.stats.findFirst({
|
||||||
const byUser = await prisma.image.groupBy({
|
orderBy: {
|
||||||
by: ['userId'],
|
created_at: 'desc',
|
||||||
_count: {
|
|
||||||
_all: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const count_users = await prisma.user.count();
|
|
||||||
|
|
||||||
const count_by_user = [];
|
|
||||||
for (let i = 0, L = byUser.length; i !== L; ++i) {
|
|
||||||
const user = await prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
id: byUser[i].userId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
count_by_user.push({
|
|
||||||
username: user.username,
|
|
||||||
count: byUser[i]._count._all
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = await prisma.image.count();
|
|
||||||
const viewsCount = await prisma.image.groupBy({
|
|
||||||
by: ['views'],
|
|
||||||
_sum: {
|
|
||||||
views: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const typesCount = await prisma.image.groupBy({
|
|
||||||
by: ['mimetype'],
|
|
||||||
_count: {
|
|
||||||
mimetype: true
|
|
||||||
},
|
},
|
||||||
|
take: 1,
|
||||||
});
|
});
|
||||||
const types_count = [];
|
|
||||||
for (let i = 0, L = typesCount.length; i !== L; ++i) types_count.push({ mimetype: typesCount[i].mimetype, count: typesCount[i]._count.mimetype });
|
|
||||||
|
|
||||||
return res.json({
|
return res.json(stats.data);
|
||||||
size: bytesToRead(size),
|
|
||||||
size_num: size,
|
|
||||||
count,
|
|
||||||
count_by_user: count_by_user.sort((a,b) => b.count-a.count),
|
|
||||||
count_users,
|
|
||||||
views_count: (viewsCount[0]?._sum?.views ?? 0),
|
|
||||||
types_count: types_count.sort((a,b) => b.count-a.count)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withZipline(handler);
|
export default withZipline(handler);
|
||||||
@@ -2,59 +2,104 @@ import multer from 'multer';
|
|||||||
import prisma from 'lib/prisma';
|
import prisma from 'lib/prisma';
|
||||||
import zconfig from 'lib/config';
|
import zconfig from 'lib/config';
|
||||||
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
|
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
|
||||||
import { createInvis, randomChars } from 'lib/util';
|
import { createInvisImage, randomChars } from 'lib/util';
|
||||||
import { writeFile } from 'fs/promises';
|
import { writeFile } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import Logger from 'lib/logger';
|
import Logger from 'lib/logger';
|
||||||
|
import { ImageFormat, InvisibleImage } from '@prisma/client';
|
||||||
|
import { format as formatDate } from 'fecha';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
const uploader = multer({
|
const uploader = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
if (req.method !== 'POST') return res.forbid('no allow');
|
if (req.method !== 'POST') return res.forbid('invalid method');
|
||||||
if (!req.headers.authorization) return res.forbid('no authorization');
|
if (!req.headers.authorization) return res.forbid('no authorization');
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
token: req.headers.authorization
|
token: req.headers.authorization,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
if (!user) return res.forbid('authorization incorect');
|
if (!user) return res.forbid('authorization incorect');
|
||||||
|
if (user.ratelimited) return res.ratelimited();
|
||||||
if (!req.files) return res.error('no files');
|
if (!req.files) return res.error('no files');
|
||||||
if (req.files && req.files.length === 0) return res.error('no files');
|
if (req.files && req.files.length === 0) return res.error('no files');
|
||||||
|
|
||||||
|
const rawFormat = ((req.headers.format || '') as string).toUpperCase() || 'RANDOM';
|
||||||
|
const format: ImageFormat = Object.keys(ImageFormat).includes(rawFormat) && ImageFormat[rawFormat];
|
||||||
const files = [];
|
const files = [];
|
||||||
|
|
||||||
for (let i = 0; i !== req.files.length; ++i) {
|
for (let i = 0; i !== req.files.length; ++i) {
|
||||||
const file = req.files[i];
|
const file = req.files[i];
|
||||||
if (file.size > zconfig.uploader[user.administrator ? 'admin_limit' : 'user_limit']) return res.error('file size too big');
|
if (file.size > zconfig.uploader[user.administrator ? 'admin_limit' : 'user_limit']) return res.error(`file[${i}] size too big`);
|
||||||
|
|
||||||
const ext = file.originalname.split('.').pop();
|
const ext = file.originalname.split('.').pop();
|
||||||
if (zconfig.uploader.disabled_extentions.includes(ext)) return res.error('disabled extension recieved: ' + ext);
|
if (zconfig.uploader.disabled_extentions.includes(ext)) return res.error('disabled extension recieved: ' + ext);
|
||||||
const rand = randomChars(zconfig.uploader.length);
|
let fileName: string;
|
||||||
|
|
||||||
let invis;
|
switch (format) {
|
||||||
|
case ImageFormat.RANDOM:
|
||||||
|
fileName = randomChars(zconfig.uploader.length);
|
||||||
|
break;
|
||||||
|
case ImageFormat.DATE:
|
||||||
|
fileName = formatDate(new Date(), 'YYYY-MM-DD_HH:mm:ss');
|
||||||
|
break;
|
||||||
|
case ImageFormat.UUID:
|
||||||
|
fileName = v4();
|
||||||
|
break;
|
||||||
|
case ImageFormat.NAME:
|
||||||
|
fileName = file.originalname.split('.')[0];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let invis: InvisibleImage;
|
||||||
const image = await prisma.image.create({
|
const image = await prisma.image.create({
|
||||||
data: {
|
data: {
|
||||||
file: `${rand}.${ext}`,
|
file: `${fileName}.${ext}`,
|
||||||
mimetype: file.mimetype,
|
mimetype: file.mimetype,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
embed: !!req.headers.embed
|
embed: !!req.headers.embed,
|
||||||
}
|
format,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (req.headers.zws) invis = await createInvis(zconfig.uploader.length, image.id);
|
if (req.headers.zws) invis = await createInvisImage(zconfig.uploader.length, image.id);
|
||||||
|
|
||||||
await writeFile(join(process.cwd(), zconfig.uploader.directory, image.file), file.buffer);
|
await writeFile(join(process.cwd(), zconfig.uploader.directory, image.file), file.buffer);
|
||||||
Logger.get('image').info(`User ${user.username} (${user.id}) uploaded an image ${image.file} (${image.id})`);
|
Logger.get('image').info(`User ${user.username} (${user.id}) uploaded an image ${image.file} (${image.id})`);
|
||||||
files.push(`${zconfig.core.secure ? 'https' : 'http'}://${req.headers.host}${zconfig.uploader.route}/${invis ? invis.invis : image.file}`);
|
files.push(`${zconfig.core.secure ? 'https' : 'http'}://${req.headers.host}${zconfig.uploader.route}/${invis ? invis.invis : image.file}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// url will be deprecated soon
|
if (user.administrator && zconfig.ratelimit.admin !== 0) {
|
||||||
return res.json({ files, url: files[0] });
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
ratelimited: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setTimeout(async () => await prisma.user.update({ where: { id: user.id }, data: { ratelimited: false } }), zconfig.ratelimit.admin * 1000).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.administrator && zconfig.ratelimit.user !== 0) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
ratelimited: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setTimeout(async () => await prisma.user.update({ where: { id: user.id }, data: { ratelimited: false } }), zconfig.ratelimit.user * 1000).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ files });
|
||||||
}
|
}
|
||||||
|
|
||||||
function run(middleware: any) {
|
function run(middleware: any) {
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||||||
|
|
||||||
const image = await prisma.image.delete({
|
const image = await prisma.image.delete({
|
||||||
where: {
|
where: {
|
||||||
id: req.body.id
|
id: req.body.id,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await rm(join(process.cwd(), config.uploader.directory, image.file));
|
await rm(join(process.cwd(), config.uploader.directory, image.file));
|
||||||
@@ -32,8 +32,8 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||||||
if (req.body.favorite !== null) image = await prisma.image.update({
|
if (req.body.favorite !== null) image = await prisma.image.update({
|
||||||
where: { id: req.body.id },
|
where: { id: req.body.id },
|
||||||
data: {
|
data: {
|
||||||
favorite: req.body.favorite
|
favorite: req.body.favorite,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json(image);
|
return res.json(image);
|
||||||
@@ -41,15 +41,15 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||||||
let images = await prisma.image.findMany({
|
let images = await prisma.image.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
favorite: !!req.query.favorite
|
favorite: !!req.query.favorite,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
created_at: true,
|
created_at: true,
|
||||||
file: true,
|
file: true,
|
||||||
mimetype: true,
|
mimetype: true,
|
||||||
id: true,
|
id: true,
|
||||||
favorite: true
|
favorite: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,74 +12,61 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||||||
const hashed = await hashPassword(req.body.password);
|
const hashed = await hashPassword(req.body.password);
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { password: hashed }
|
data: { password: hashed },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.body.username) {
|
if (req.body.username) {
|
||||||
const existing = await prisma.user.findFirst({
|
const existing = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
username: req.body.username
|
username: req.body.username,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
if (existing && user.username !== req.body.username) {
|
if (existing && user.username !== req.body.username) {
|
||||||
return res.forbid('Username is already taken');
|
return res.forbid('Username is already taken');
|
||||||
}
|
}
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { username: req.body.username }
|
data: { username: req.body.username },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.body.embedTitle) await prisma.user.update({
|
if (req.body.embedTitle) await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { embedTitle: req.body.embedTitle }
|
data: { embedTitle: req.body.embedTitle },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (req.body.embedColor) await prisma.user.update({
|
if (req.body.embedColor) await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { embedColor: req.body.embedColor }
|
data: { embedColor: req.body.embedColor },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.body.embedSiteName) await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { embedSiteName: req.body.embedSiteName },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (req.body.systemTheme) await prisma.user.update({
|
if (req.body.systemTheme) await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { systemTheme: req.body.systemTheme }
|
data: { systemTheme: req.body.systemTheme },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (req.body.customTheme) {
|
|
||||||
if (user.customTheme) await prisma.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: {
|
|
||||||
customTheme: {
|
|
||||||
update: {
|
|
||||||
...req.body.customTheme
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}); else await prisma.theme.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
...req.body.customTheme
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const newUser = await prisma.user.findFirst({
|
const newUser = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: Number(user.id)
|
id: Number(user.id),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
administrator: true,
|
administrator: true,
|
||||||
embedColor: true,
|
embedColor: true,
|
||||||
embedTitle: true,
|
embedTitle: true,
|
||||||
|
embedSiteName: true,
|
||||||
id: true,
|
id: true,
|
||||||
images: false,
|
images: false,
|
||||||
password: false,
|
password: false,
|
||||||
systemTheme: true,
|
systemTheme: true,
|
||||||
customTheme: true,
|
|
||||||
token: true,
|
token: true,
|
||||||
username: true
|
username: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
Logger.get('user').info(`User ${user.username} (${newUser.username}) (${newUser.id}) was updated`);
|
Logger.get('user').info(`User ${user.username} (${newUser.username}) (${newUser.id}) was updated`);
|
||||||
|
|||||||
@@ -11,14 +11,18 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||||||
|
|
||||||
let images = await prisma.image.findMany({
|
let images = await prisma.image.findMany({
|
||||||
take,
|
take,
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
created_at: 'desc'
|
created_at: 'desc',
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
created_at: true,
|
created_at: true,
|
||||||
file: true,
|
file: true,
|
||||||
mimetype: true
|
mimetype: true,
|
||||||
}
|
id: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||||||
if (req.method === 'PATCH') {
|
if (req.method === 'PATCH') {
|
||||||
const updated = await prisma.user.update({
|
const updated = await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: user.id
|
id: user.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
token: createToken()
|
token: createToken(),
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
Logger.get('user').info(`User ${user.username} (${user.id}) reset their token`);
|
Logger.get('user').info(`User ${user.username} (${user.id}) reset their token`);
|
||||||
|
|||||||
41
src/pages/api/user/urls.ts
Normal file
41
src/pages/api/user/urls.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||||
|
import prisma from 'lib/prisma';
|
||||||
|
import config from 'lib/config';
|
||||||
|
import Logger from 'lib/logger';
|
||||||
|
|
||||||
|
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
|
const user = await req.user();
|
||||||
|
if (!user) return res.forbid('not logged in');
|
||||||
|
|
||||||
|
if (req.method === 'DELETE') {
|
||||||
|
if (!req.body.id) return res.error('no url id');
|
||||||
|
|
||||||
|
const url = await prisma.url.delete({
|
||||||
|
where: {
|
||||||
|
id: req.body.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Logger.get('url').info(`User ${user.username} (${user.id}) deleted a url ${url.destination} (${url.id})`);
|
||||||
|
|
||||||
|
return res.json(url);
|
||||||
|
} else {
|
||||||
|
let urls = await prisma.url.findMany({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
created_at: true,
|
||||||
|
id: true,
|
||||||
|
destination: true,
|
||||||
|
vanity: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
urls.map(url => url.url = `${config.urls.route}/${url.vanity ?? url.id}`);
|
||||||
|
return res.json(urls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withZipline(handler);
|
||||||
@@ -1,34 +1,31 @@
|
|||||||
import { join } from 'path';
|
|
||||||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||||
import prisma from 'lib/prisma';
|
import prisma from 'lib/prisma';
|
||||||
import { bytesToRead, sizeOfDir } from 'lib/util';
|
|
||||||
import { tryGetPreviewData } from 'next/dist/server/api-utils';
|
|
||||||
|
|
||||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
const user = await req.user();
|
const user = await req.user();
|
||||||
if (!user) return res.forbid('not logged in');
|
if (!user) return res.forbid('not logged in');
|
||||||
if (!user.administrator) return res.forbid('you arent an administrator');
|
if (!user.administrator) return res.forbid('you aren\'t an administrator');
|
||||||
|
|
||||||
if (req.method === 'DELETE') {
|
if (req.method === 'DELETE') {
|
||||||
if (req.body.id === user.id) return res.forbid('you can\'t delete your own account');
|
if (req.body.id === user.id) return res.forbid('you can\'t delete your own account');
|
||||||
|
|
||||||
const deleteUser = await prisma.user.findFirst({
|
const deleteUser = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: req.body.id
|
id: req.body.id,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
if (!deleteUser) return res.forbid('user doesn\'t exist');
|
if (!deleteUser) return res.forbid('user doesn\'t exist');
|
||||||
|
|
||||||
await prisma.user.delete({
|
await prisma.user.delete({
|
||||||
where: {
|
where: {
|
||||||
id: deleteUser.id
|
id: deleteUser.id,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
delete deleteUser.password;
|
delete deleteUser.password;
|
||||||
return res.json(deleteUser);
|
return res.json(deleteUser);
|
||||||
} else {
|
} else {
|
||||||
const all_users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
select: {
|
select: {
|
||||||
username: true,
|
username: true,
|
||||||
id: true,
|
id: true,
|
||||||
@@ -36,11 +33,10 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||||||
token: true,
|
token: true,
|
||||||
embedColor: true,
|
embedColor: true,
|
||||||
embedTitle: true,
|
embedTitle: true,
|
||||||
customTheme: true,
|
systemTheme: true,
|
||||||
systemTheme: true
|
},
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return res.json(all_users);
|
return res.json(users);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
src/pages/api/version.ts
Normal file
17
src/pages/api/version.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||||
|
|
||||||
|
|
||||||
|
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
|
const pkg = JSON.parse(await readFile('package.json', 'utf8'));
|
||||||
|
|
||||||
|
const re = await fetch('https://raw.githubusercontent.com/diced/zipline/trunk/package.json');
|
||||||
|
const upstreamPkg = await re.json();
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
local: pkg.version,
|
||||||
|
upstream: upstreamPkg.version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withZipline(handler);
|
||||||
@@ -1,102 +1,96 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Typography, Box, TextField, Stack, Button } from '@material-ui/core';
|
|
||||||
import { Color } from '@material-ui/core/Alert/Alert';
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import Alert from 'components/Alert';
|
|
||||||
import Backdrop from 'components/Backdrop';
|
|
||||||
import useFetch from 'hooks/useFetch';
|
import useFetch from 'hooks/useFetch';
|
||||||
import { useFormik } from 'formik';
|
import { useForm } from '@mantine/hooks';
|
||||||
|
import { TextInput, Button, Center, Title, Box, Badge, Tooltip } from '@mantine/core';
|
||||||
|
import { useNotifications } from '@mantine/notifications';
|
||||||
function TextInput({ id, label, formik, ...other }) {
|
import { Cross1Icon, DownloadIcon } from '@modulz/radix-icons';
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<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'
|
|
||||||
sx={{ pb: 0.5 }}
|
|
||||||
{...other}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [severity, setSeverity] = useState<Color>('success');
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
const [loadingOpen, setLoadingOpen] = useState(false);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const notif = useNotifications();
|
||||||
|
const [versions, setVersions] = React.useState<{ upstream: string, local: string }>(null);
|
||||||
|
|
||||||
const formik = useFormik({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
username: '',
|
username: '',
|
||||||
password: ''
|
password: '',
|
||||||
},
|
},
|
||||||
onSubmit: async values => {
|
});
|
||||||
|
|
||||||
|
const onSubmit = async values => {
|
||||||
const username = values.username.trim();
|
const username = values.username.trim();
|
||||||
const password = values.password.trim();
|
const password = values.password.trim();
|
||||||
|
|
||||||
if (username === '') return formik.setFieldError('username', 'Username can\'t be nothing');
|
if (username === '') return form.setFieldError('username', 'Username can\'t be nothing');
|
||||||
|
|
||||||
setLoadingOpen(true);
|
|
||||||
const res = await useFetch('/api/auth/login', 'POST', {
|
const res = await useFetch('/api/auth/login', 'POST', {
|
||||||
username, password
|
username, password,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
setOpen(true);
|
notif.showNotification({
|
||||||
setSeverity('error');
|
title: 'Login Failed',
|
||||||
setMessage(res.error);
|
message: res.error,
|
||||||
setLoadingOpen(false);
|
color: 'red',
|
||||||
} else {
|
icon: <Cross1Icon />,
|
||||||
setOpen(true);
|
|
||||||
setSeverity('success');
|
|
||||||
setMessage('Logged in');
|
|
||||||
router.push('/dashboard');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
router.push(router.query.url as string || '/dashboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const a = await fetch('/api/user');
|
const a = await fetch('/api/user');
|
||||||
if (a.ok) router.push('/dashboard');
|
if (a.ok) router.push('/dashboard');
|
||||||
|
else {
|
||||||
|
const v = await useFetch('/api/version');
|
||||||
|
setVersions(v);
|
||||||
|
if (v.local !== v.upstream) {
|
||||||
|
notif.showNotification({
|
||||||
|
title: 'Update available',
|
||||||
|
message: `A new version of Zipline is available. You are running ${v.local} and the latest version is ${v.upstream}.`,
|
||||||
|
icon: <DownloadIcon />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Alert open={open} setOpen={setOpen} severity={severity} message={message} />
|
<Center sx={{ height: '100vh' }}>
|
||||||
<Backdrop open={loadingOpen} />
|
<div>
|
||||||
<Box
|
<Title align='center'>Zipline</Title>
|
||||||
display='flex'
|
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||||
height='screen'
|
<TextInput size='lg' id='username' label='Username' {...form.getInputProps('username')} />
|
||||||
alignItems='center'
|
<TextInput size='lg' id='password' label='Password' type='password' {...form.getInputProps('password')} />
|
||||||
justifyContent='center'
|
|
||||||
sx={{ height: '24rem' }}
|
|
||||||
>
|
|
||||||
<Stack>
|
|
||||||
<Typography variant='h3' textAlign='center'>
|
|
||||||
Zipline
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<form onSubmit={formik.handleSubmit}>
|
<Button size='lg' type='submit' fullWidth mt={12}>Login</Button>
|
||||||
<TextInput formik={formik} id='username' label='Username' />
|
|
||||||
<TextInput formik={formik} id='password' label='Password' type='password' />
|
|
||||||
<Box my={2}>
|
|
||||||
<Button variant='contained' fullWidth type='submit'>
|
|
||||||
Login
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</form>
|
</form>
|
||||||
</Stack>
|
</div>
|
||||||
|
</Center>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
zIndex: 99,
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '10px',
|
||||||
|
right: '20px',
|
||||||
|
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{versions && (
|
||||||
|
<Tooltip
|
||||||
|
wrapLines
|
||||||
|
width={220}
|
||||||
|
transition='rotate-left'
|
||||||
|
transitionDuration={200}
|
||||||
|
label={versions.local !== versions.upstream ? 'Looks like you are running an outdated version of Zipline. Please update to the latest version.' : 'You are running the latest version of Zipline.'}
|
||||||
|
>
|
||||||
|
<Badge radius='md' size='lg' variant='dot' color={versions.local !== versions.upstream ? 'red' : 'primary'}>{versions.local}</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { Backdrop, CircularProgress } from '@material-ui/core';
|
import { LoadingOverlay } from '@mantine/core';
|
||||||
|
|
||||||
export default function Logout() {
|
export default function Logout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [visible, setVisible] = useState(true);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -18,12 +20,7 @@ export default function Logout() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Backdrop
|
<LoadingOverlay visible={visible} />
|
||||||
sx={{ color: '#fff', zIndex: t => t.zIndex.drawer + 1 }}
|
|
||||||
open
|
|
||||||
>
|
|
||||||
<CircularProgress color='inherit' />
|
|
||||||
</Backdrop>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
23
src/pages/code/[id].tsx
Normal file
23
src/pages/code/[id].tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import exts from '../../../scripts/exts';
|
||||||
|
import { Prism } from '@mantine/prism';
|
||||||
|
|
||||||
|
export default function Code() {
|
||||||
|
const [prismRenderCode, setPrismRenderCode] = React.useState('');
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = router.query as { id: string };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const res = await fetch('/r/' + id);
|
||||||
|
if (id && !res.ok) router.push('/404');
|
||||||
|
const data = await res.text();
|
||||||
|
if (id) setPrismRenderCode(data);
|
||||||
|
})();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return id && prismRenderCode ? (
|
||||||
|
<Prism sx={t => ({ height: '100vh', backgroundColor: t.colors.dark[8] })} withLineNumbers language={exts[id.split('.').pop()]}>{prismRenderCode}</Prism>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import useLogin from 'hooks/useLogin';
|
|||||||
import Layout from 'components/Layout';
|
import Layout from 'components/Layout';
|
||||||
import Files from 'components/pages/Files';
|
import Files from 'components/pages/Files';
|
||||||
|
|
||||||
export default function ImagesPage() {
|
export default function FilesPage() {
|
||||||
const { user, loading } = useLogin();
|
const { user, loading } = useLogin();
|
||||||
|
|
||||||
if (loading) return null;
|
if (loading) return null;
|
||||||
@@ -11,12 +11,10 @@ export default function ImagesPage() {
|
|||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
user={user}
|
user={user}
|
||||||
loading={loading}
|
|
||||||
noPaper={false}
|
|
||||||
>
|
>
|
||||||
<Files />
|
<Files />
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ImagesPage.title = 'Zipline - Gallery';
|
FilesPage.title = 'Zipline - Gallery';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user