mirror of
https://github.com/diced/zipline.git
synced 2025-12-25 04:15:41 -08:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99e92e4594 | ||
|
|
870f6e88b1 | ||
|
|
16d2014bfb | ||
|
|
4d9a22e82c | ||
|
|
42d77e445b | ||
|
|
6506846207 | ||
|
|
2b9af0e0de | ||
|
|
762d2927f7 | ||
|
|
d9561f3b12 | ||
|
|
dde24848d4 | ||
|
|
e786482902 | ||
|
|
4e64922b70 | ||
|
|
15042b16d1 | ||
|
|
5e4c4fc6c9 | ||
|
|
7194c53891 | ||
|
|
7eff77ccc4 | ||
|
|
1b78ffaa91 | ||
|
|
8e8bfd68d1 | ||
|
|
b029505cdd | ||
|
|
c5c862bee3 | ||
|
|
3c38d008f1 | ||
|
|
dc52b00a00 | ||
|
|
b5d2e7040e | ||
|
|
5818440721 | ||
|
|
f1c46da47d | ||
|
|
212c69d303 | ||
|
|
9e4152e298 | ||
|
|
307f023e47 | ||
|
|
3451bd8762 | ||
|
|
a9d0be8aae | ||
|
|
d83f720631 | ||
|
|
1f3d396296 | ||
|
|
48f771f344 | ||
|
|
555bc6aa26 | ||
|
|
8bd0eaac1e | ||
|
|
3280c77002 | ||
|
|
b39743a53a | ||
|
|
9a73da56e9 | ||
|
|
c9b0d2664f | ||
|
|
6063c9efac | ||
|
|
dd6f192d4a | ||
|
|
d956f4ed3d | ||
|
|
4728258750 | ||
|
|
ece3e16459 | ||
|
|
9208dbe2f3 | ||
|
|
636de18642 | ||
|
|
ee48456291 | ||
|
|
a06d5ffaed | ||
|
|
606821a2c0 | ||
|
|
5c980c21e5 | ||
|
|
771cc380df | ||
|
|
38217870fe | ||
|
|
5b82c96a43 | ||
|
|
6f5f9869ad | ||
|
|
b29bfeb8b1 | ||
|
|
cb40559e49 | ||
|
|
90c72f7ffe | ||
|
|
002bd2e6f7 | ||
|
|
7b44f17a64 | ||
|
|
b5c83f92e3 | ||
|
|
51b4d64a93 | ||
|
|
62c9e0a22f | ||
|
|
3daac34d3e | ||
|
|
d80d5d1632 | ||
|
|
912f716362 | ||
|
|
16ecdf41af | ||
|
|
f0bb6b08fa | ||
|
|
efb4e2ce9a | ||
|
|
03238d10bf | ||
|
|
e71590b9fb | ||
|
|
4728f1cc46 | ||
|
|
794778dee2 | ||
|
|
b5e882f07e | ||
|
|
e7c58a4847 | ||
|
|
bdb44db25e | ||
|
|
e8b82ffe62 | ||
|
|
53c53c009e |
20
.babelrc
20
.babelrc
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
"next/babel"
|
||||
],
|
||||
"plugins": [
|
||||
[
|
||||
"babel-plugin-transform-imports",
|
||||
{
|
||||
"@material-ui/core": {
|
||||
"transform": "@material-ui/core/${member}",
|
||||
"preventFullImport": true
|
||||
},
|
||||
"@material-ui/icons": {
|
||||
"transform": "@material-ui/icons/${member}",
|
||||
"preventFullImport": true
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
9
.github/workflows/build.yml
vendored
9
.github/workflows/build.yml
vendored
@@ -23,12 +23,11 @@ jobs:
|
||||
key: ${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
|
||||
|
||||
- name: Create mock config
|
||||
run: echo -e "[uploader]\nroute = '/u'\nembed_route = '/a'\nlength = 6\ndirectory = './uploads'" > config.toml
|
||||
|
||||
run: echo -e "[core]\nsecret = '12345678'\ndatabase_url = 'postgres://postgres:postgres@postgres/postgres'\n[uploader]\nroute = '/u'\ndirectory = './uploads'\n[urls]\nroute = '/go'" > config.toml
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache-restore.outputs.cache-hit != 'true'
|
||||
run: yarn install
|
||||
|
||||
|
||||
- name: Build
|
||||
run: yarn build
|
||||
|
||||
run: yarn build
|
||||
1
.github/workflows/docker.yml
vendored
1
.github/workflows/docker.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
- 'src/**'
|
||||
- 'server/**'
|
||||
- 'prisma/**'
|
||||
- '.github/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -18,6 +18,7 @@
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.idea
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
@@ -36,4 +37,5 @@ yarn-error.log*
|
||||
# zipline
|
||||
config.toml
|
||||
uploads/
|
||||
data.db*
|
||||
dist/
|
||||
docker-compose.local.yml
|
||||
1
.husky/.gitignore
vendored
1
.husky/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
_
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
yarn commitlint --edit $1
|
||||
40
Dockerfile
40
Dockerfile
@@ -1,31 +1,45 @@
|
||||
FROM node:16-alpine3.11 AS builder
|
||||
FROM node:16-alpine AS deps
|
||||
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 server ./server
|
||||
COPY scripts ./scripts
|
||||
COPY prisma ./prisma
|
||||
COPY package.json yarn.lock esbuild.config.js next.config.js next-env.d.ts zip-env.d.ts tsconfig.json ./
|
||||
|
||||
COPY package.json yarn.lock next.config.js next-env.d.ts zip-env.d.ts tsconfig.json ./
|
||||
|
||||
RUN yarn install
|
||||
|
||||
# create a mock config.toml to spoof next build!
|
||||
RUN echo -e "[uploader]\nroute = '/u'\nembed_route = '/a'\nlength = 6\ndirectory = './uploads'" > config.toml
|
||||
ENV ZIPLINE_DOCKER_BUILD 1
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN yarn build
|
||||
|
||||
FROM node:16-alpine3.11 AS runner
|
||||
FROM node:16-alpine AS runner
|
||||
WORKDIR /zipline
|
||||
|
||||
COPY --from=builder /build/node_modules ./node_modules
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 zipline
|
||||
RUN adduser --system --uid 1001 zipline
|
||||
|
||||
COPY --from=builder --chown=zipline:zipline /build/.next ./.next
|
||||
COPY --from=builder --chown=zipline:zipline /build/dist ./dist
|
||||
COPY --from=builder --chown=zipline:zipline /build/node_modules ./node_modules
|
||||
|
||||
COPY --from=builder /build/next.config.js ./next.config.js
|
||||
COPY --from=builder /build/src ./src
|
||||
COPY --from=builder /build/server ./server
|
||||
COPY --from=builder /build/scripts ./scripts
|
||||
COPY --from=builder /build/prisma ./prisma
|
||||
COPY --from=builder /build/.next ./.next
|
||||
COPY --from=builder /build/tsconfig.json ./tsconfig.json
|
||||
COPY --from=builder /build/package.json ./package.json
|
||||
|
||||
CMD ["node", "server"]
|
||||
USER zipline
|
||||
|
||||
CMD ["node", "dist/server"]
|
||||
@@ -1,3 +0,0 @@
|
||||
prisma/migrations
|
||||
node_modules
|
||||
.next
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 dicedtomato
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
45
README.md
45
README.md
@@ -1,25 +1,34 @@
|
||||
<p align="center"><img src="https://raw.githubusercontent.com/diced/zipline/trunk/public/zipline_small.png"/></p>
|
||||
|
||||

|
||||

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

|
||||

|
||||

|
||||
<br>
|
||||
|
||||
# Zipline
|
||||
|
||||
Fast & lightweight file uploading.
|
||||
|
||||
# Features
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/diced/zipline/trunk/public/zipline_small.png"/>
|
||||
|
||||
Zipline is a ShareX/file upload server that is easy to use, packed with features and can be setup in one command!
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://discord.gg/EAhCRfGxCF)
|
||||
|
||||
</div>
|
||||
|
||||
## Features
|
||||
- Configurable
|
||||
- Fast
|
||||
- Built with Next.js & React
|
||||
- Token protected uploading
|
||||
- Easy setup instructions on [docs](https://zipline.diced.me) (One command install `docker-compose up`)
|
||||
- Image uploading
|
||||
- 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.tech/docs/get-started)
|
||||
|
||||
[See how to install here](https://zipline.diced.me/docs/getting-started)
|
||||
## Configuration
|
||||
[See how to configure here](https://zipline.diced.tech/docs/config/overview)
|
||||
|
||||
## Theming
|
||||
[See how to theme here](https://zipline.diced.tech/docs/themes/reference)
|
||||
|
||||
12
SECURITY.md
Normal file
12
SECURITY.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 3.2.x | :white_check_mark: |
|
||||
| < 3 | :x: |
|
||||
| < 2 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
Report a Vulnerability by issuing a bug report, with exact details with how the vulnerability happened, what "exploits" can happen, and possible fixes (optional). Vulnerability reports are treated with high priority and will be resolved most of the time quickly.
|
||||
@@ -1,53 +0,0 @@
|
||||
module.exports = {
|
||||
parserPreset: 'conventional-changelog-conventionalcommits',
|
||||
rules: {
|
||||
'body-leading-blank': [1, 'always'],
|
||||
'body-max-line-length': [2, 'always', 100],
|
||||
'footer-leading-blank': [1, 'always'],
|
||||
'footer-max-line-length': [2, 'always', 100],
|
||||
'header-max-length': [2, 'always', 100],
|
||||
'subject-case': [
|
||||
2,
|
||||
'never',
|
||||
['sentence-case', 'start-case', 'pascal-case', 'upper-case'],
|
||||
],
|
||||
'subject-empty': [2, 'never'],
|
||||
'subject-full-stop': [2, 'never', '.'],
|
||||
'type-case': [2, 'always', 'lower-case'],
|
||||
'type-empty': [2, 'never'],
|
||||
'type-enum': [
|
||||
2,
|
||||
'always',
|
||||
[
|
||||
'build',
|
||||
'chore',
|
||||
'ci',
|
||||
'docs',
|
||||
'feat',
|
||||
'fix',
|
||||
'perf',
|
||||
'refactor',
|
||||
'revert',
|
||||
'style',
|
||||
'test',
|
||||
],
|
||||
],
|
||||
'scope-enum': [
|
||||
1,
|
||||
'always',
|
||||
[
|
||||
'prisma',
|
||||
'scripts',
|
||||
'server',
|
||||
'pages',
|
||||
'api',
|
||||
'hooks',
|
||||
'components',
|
||||
'middleware',
|
||||
'redux',
|
||||
'themes',
|
||||
'lib'
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -3,13 +3,17 @@ secure = true
|
||||
secret = 'some secret'
|
||||
host = '0.0.0.0'
|
||||
port = 3000
|
||||
database_url = 'postgres://postgres:postgres@postgres/postgres'
|
||||
|
||||
[database]
|
||||
type = 'psql'
|
||||
url = 'postgres://postgres:postgres@postgres/postgres'
|
||||
[urls]
|
||||
route = '/go'
|
||||
length = 6
|
||||
|
||||
[uploader]
|
||||
route = '/u'
|
||||
embed_route = '/a'
|
||||
length = 6
|
||||
directory = './uploads'
|
||||
directory = './uploads'
|
||||
user_limit = 104900000 # 100mb
|
||||
admin_limit = 104900000 # 100mb
|
||||
disabled_extentions = ['jpg']
|
||||
|
||||
47
docker-compose.dev.yml
Normal file
47
docker-compose.dev.yml
Normal file
@@ -0,0 +1,47 @@
|
||||
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
|
||||
- DATASOURCE_TYPE=local
|
||||
- DATASOURCE_DIRECTORY=./uploads
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
|
||||
- UPLOADER_ROUTE=/u
|
||||
- UPLOADER_EMBED_ROUTE=/a
|
||||
- UPLOADER_LENGTH=6
|
||||
- UPLOADER_ADMIN_LIMIT=104900000
|
||||
- UPLOADER_USER_LIMIT=104900000
|
||||
- UPLOADER_DISABLED_EXTS=
|
||||
- URLS_ROUTE=/go
|
||||
- URLS_LENGTH=6
|
||||
volumes:
|
||||
- '$PWD/uploads:/zipline/uploads'
|
||||
- '$PWD/public:/zipline/public'
|
||||
depends_on:
|
||||
- 'postgres'
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
retries: 5
|
||||
|
||||
zipline:
|
||||
image: diced/zipline:trunk
|
||||
image: ghcr.io/diced/zipline/zipline:trunk
|
||||
ports:
|
||||
- '3000:3000'
|
||||
restart: unless-stopped
|
||||
@@ -24,15 +24,19 @@ services:
|
||||
- SECRET=changethis
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- DATABASE_TYPE=psql
|
||||
- DATASOURCE_TYPE=local
|
||||
- DATASOURCE_DIRECTORY=./uploads
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
|
||||
- UPLOADER_ROUTE=/u
|
||||
- UPLOADER_EMBED_ROUTE=/a
|
||||
- UPLOADER_LENGTH=6
|
||||
- UPLOADER_DIRECTORY=./uploads
|
||||
- UPLOADER_ADMIN_LIMIT=104900000
|
||||
- UPLOADER_USER_LIMIT=104900000
|
||||
- UPLOADER_DISABLED_EXTS=
|
||||
- URLS_ROUTE=/go
|
||||
- URLS_LENGTH=6
|
||||
volumes:
|
||||
- '$PWD/uploads:/zipline/uploads'
|
||||
- '$PWD/prisma:/zipline/prisma'
|
||||
- '$PWD/public:/zipline/public'
|
||||
depends_on:
|
||||
- 'postgres'
|
||||
|
||||
33
esbuild.config.js
Normal file
33
esbuild.config.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const esbuild = require('esbuild');
|
||||
|
||||
(async () => {
|
||||
const watch = process.argv[2] === '--watch';
|
||||
|
||||
await esbuild.build({
|
||||
tsconfig: 'tsconfig.json',
|
||||
outdir: 'dist',
|
||||
bundle: false,
|
||||
platform: 'node',
|
||||
treeShaking: true,
|
||||
entryPoints: [
|
||||
'src/server/index.ts',
|
||||
'src/server/util.ts',
|
||||
'src/server/validateConfig.ts',
|
||||
'src/lib/logger.ts',
|
||||
'src/lib/readConfig.ts',
|
||||
'src/lib/datasource/datasource.ts',
|
||||
'src/lib/datasource/index.ts',
|
||||
'src/lib/datasource/Local.ts',
|
||||
'src/lib/datasource/S3.ts',
|
||||
'src/lib/ds.ts',
|
||||
'src/lib/config.ts',
|
||||
],
|
||||
format: 'cjs',
|
||||
resolveExtensions: ['.ts', '.js'],
|
||||
write: true,
|
||||
watch,
|
||||
incremental: watch,
|
||||
sourcemap: false,
|
||||
minify: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
})();
|
||||
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
@@ -1,5 +1,4 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/types/global" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
module.exports = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/',
|
||||
destination: '/dashboard',
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
60
package.json
60
package.json
@@ -1,62 +1,66 @@
|
||||
{
|
||||
"name": "zip3",
|
||||
"version": "3.2.0",
|
||||
"version": "3.4.1",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"prepare": "husky install",
|
||||
"dev": "NODE_ENV=development node server",
|
||||
"build": "npm-run-all build:schema build:next",
|
||||
"dev": "node esbuild.config.js && REACT_EDITOR=code-insiders NODE_ENV=development node dist/server",
|
||||
"build": "npm-run-all build:server build:schema build:next",
|
||||
"build:server": "node esbuild.config.js",
|
||||
"build:next": "next build",
|
||||
"build:schema": "prisma generate --schema=prisma/schema.prisma",
|
||||
"migrate:dev": "prisma migrate dev --create-only",
|
||||
"start": "node server",
|
||||
"lint": "next lint",
|
||||
"ts-node": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only",
|
||||
"create-all-migrations": "node scripts/create-migrations",
|
||||
"semantic-release": "semantic-release"
|
||||
"seed": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only prisma/seed.ts",
|
||||
"docker:run": "docker-compose up -d",
|
||||
"docker:down": "docker-compose down",
|
||||
"docker:build-dev": "docker-compose --file docker-compose.dev.yml up --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.4.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@iarna/toml": "2.2.5",
|
||||
"@material-ui/core": "^5.0.0-alpha.37",
|
||||
"@material-ui/data-grid": "^4.0.0-alpha.32",
|
||||
"@material-ui/icons": "^5.0.0-alpha.37",
|
||||
"@material-ui/styles": "^5.0.0-alpha.35",
|
||||
"@prisma/client": "^2.30.0",
|
||||
"@mantine/core": "^3.6.9",
|
||||
"@mantine/dropzone": "^3.6.9",
|
||||
"@mantine/hooks": "^3.6.9",
|
||||
"@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",
|
||||
"argon2": "^0.28.2",
|
||||
"aws-sdk": "^2.1085.0",
|
||||
"colorette": "^1.2.2",
|
||||
"cookie": "^0.4.1",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"fecha": "^4.2.1",
|
||||
"formik": "^2.2.9",
|
||||
"multer": "^1.4.2",
|
||||
"next": "11.1.0",
|
||||
"prisma": "^2.30.0",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-dropzone": "^11.3.2",
|
||||
"next": "^12.1.0",
|
||||
"prisma": "^3.9.2",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-redux": "^7.2.4",
|
||||
"react-table": "^7.7.0",
|
||||
"redux": "^4.1.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"uuid": "^8.3.2",
|
||||
"yup": "^0.32.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^12.1.4",
|
||||
"@commitlint/config-conventional": "^12.1.4",
|
||||
"@types/cookie": "^0.4.0",
|
||||
"@types/multer": "^1.4.6",
|
||||
"@types/node": "^15.12.2",
|
||||
"babel-plugin-transform-imports": "^2.0.0",
|
||||
"eslint": "7.28.0",
|
||||
"babel-plugin-import": "^1.13.3",
|
||||
"esbuild": "^0.14.23",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-next": "11.0.0",
|
||||
"husky": "^6.0.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"release": "^6.3.0",
|
||||
"ts-node": "^10.0.0",
|
||||
"typescript": "^4.3.2"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/diced/workflow-testing.git"
|
||||
"url": "https://github.com/diced/zipline.git"
|
||||
}
|
||||
}
|
||||
|
||||
2
prisma/migrations/20210827202147_favorite/migration.sql
Normal file
2
prisma/migrations/20210827202147_favorite/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" ADD COLUMN "favorite" BOOLEAN NOT NULL DEFAULT false;
|
||||
25
prisma/migrations/20210830210159_zws/migration.sql
Normal file
25
prisma/migrations/20210830210159_zws/migration.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[imageId]` on the table `InvisibleImage` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `imageId` to the `InvisibleImage` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "InvisibleImage" DROP CONSTRAINT "InvisibleImage_id_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "InvisibleImage_id_unique";
|
||||
|
||||
-- AlterTable
|
||||
CREATE SEQUENCE "invisibleimage_id_seq";
|
||||
ALTER TABLE "InvisibleImage" ADD COLUMN "imageId" INTEGER NOT NULL,
|
||||
ALTER COLUMN "id" SET DEFAULT nextval('invisibleimage_id_seq'),
|
||||
ADD PRIMARY KEY ("id");
|
||||
ALTER SEQUENCE "invisibleimage_id_seq" OWNED BY "InvisibleImage"."id";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InvisibleImage_imageId_unique" ON "InvisibleImage"("imageId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvisibleImage" ADD FOREIGN KEY ("imageId") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" ADD COLUMN "embed" BOOLEAN NOT NULL DEFAULT false;
|
||||
39
prisma/migrations/20210924045900_delete_url/migration.sql
Normal file
39
prisma/migrations/20210924045900_delete_url/migration.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `InvisibleUrl` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `Url` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Image" DROP CONSTRAINT "Image_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "InvisibleImage" DROP CONSTRAINT "InvisibleImage_imageId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "InvisibleUrl" DROP CONSTRAINT "InvisibleUrl_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Theme" DROP CONSTRAINT "Theme_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Url" DROP CONSTRAINT "Url_userId_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "InvisibleUrl";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Url";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Theme" ADD CONSTRAINT "Theme_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Image" ADD CONSTRAINT "Image_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvisibleImage" ADD CONSTRAINT "InvisibleImage_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Image"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "InvisibleImage.invis_unique" RENAME TO "InvisibleImage_invis_key";
|
||||
34
prisma/migrations/20210924050753_new_url/migration.sql
Normal file
34
prisma/migrations/20210924050753_new_url/migration.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Url" (
|
||||
"id" TEXT NOT NULL,
|
||||
"destination" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"views" INTEGER NOT NULL DEFAULT 0,
|
||||
"userId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Url_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "InvisibleUrl" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"invis" TEXT NOT NULL,
|
||||
"urlId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "InvisibleUrl_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Url_id_key" ON "Url"("id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InvisibleUrl_invis_key" ON "InvisibleUrl"("invis");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InvisibleUrl_urlId_unique" ON "InvisibleUrl"("urlId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Url" ADD CONSTRAINT "Url_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvisibleUrl" ADD CONSTRAINT "InvisibleUrl_urlId_fkey" FOREIGN KEY ("urlId") REFERENCES "Url"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Url" ADD COLUMN "vanity" TEXT;
|
||||
2
prisma/migrations/20211003022626_site_name/migration.sql
Normal file
2
prisma/migrations/20211003022626_site_name/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "embedSiteName" TEXT DEFAULT E'{image.file} • {user.name}';
|
||||
11
prisma/migrations/20211128031800_ratelimit/migration.sql
Normal file
11
prisma/migrations/20211128031800_ratelimit/migration.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "ratelimited" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "InvisibleImage_imageId_unique" RENAME TO "InvisibleImage_imageId_key";
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "InvisibleUrl_urlId_unique" RENAME TO "InvisibleUrl_urlId_key";
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "Theme_userId_unique" RENAME TO "Theme_userId_key";
|
||||
8
prisma/migrations/20220103232702_stats/migration.sql
Normal file
8
prisma/migrations/20220103232702_stats/migration.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Stats" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"data" JSONB NOT NULL,
|
||||
|
||||
CONSTRAINT "Stats_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
5
prisma/migrations/20220221053815_format/migration.sql
Normal file
5
prisma/migrations/20220221053815_format/migration.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ImageFormat" AS ENUM ('UUID', 'DATE', 'RANDOM');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" ADD COLUMN "format" "ImageFormat" NOT NULL DEFAULT E'RANDOM';
|
||||
2
prisma/migrations/20220221055817_name/migration.sql
Normal file
2
prisma/migrations/20220221055817_name/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ImageFormat" ADD VALUE 'NAME';
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
Warnings:
|
||||
- You are about to drop the `Theme` table. If the table is not empty, all the data it contains will be lost.
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Theme" DROP CONSTRAINT "Theme_userId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "systemTheme" SET DEFAULT E'system';
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Theme";
|
||||
@@ -13,27 +13,20 @@ model User {
|
||||
password String
|
||||
token String
|
||||
administrator Boolean @default(false)
|
||||
systemTheme String @default("dark_blue")
|
||||
customTheme Theme?
|
||||
systemTheme String @default("system")
|
||||
embedTitle String?
|
||||
embedColor String @default("#2f3136")
|
||||
embedSiteName String? @default("{image.file} • {user.name}")
|
||||
ratelimited Boolean @default(false)
|
||||
images Image[]
|
||||
urls Url[]
|
||||
}
|
||||
|
||||
model Theme {
|
||||
id Int @id @default(autoincrement())
|
||||
type String
|
||||
primary String
|
||||
secondary String
|
||||
error String
|
||||
warning String
|
||||
info String
|
||||
border String
|
||||
mainBackground String
|
||||
paperBackground String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
enum ImageFormat {
|
||||
UUID
|
||||
DATE
|
||||
RANDOM
|
||||
NAME
|
||||
}
|
||||
|
||||
model Image {
|
||||
@@ -42,31 +35,41 @@ model Image {
|
||||
mimetype String @default("image/png")
|
||||
created_at DateTime @default(now())
|
||||
views Int @default(0)
|
||||
favorite Boolean @default(false)
|
||||
embed Boolean @default(false)
|
||||
invisible InvisibleImage?
|
||||
format ImageFormat @default(RANDOM)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
}
|
||||
|
||||
model InvisibleImage {
|
||||
id Int
|
||||
image Image @relation(fields: [id], references: [id])
|
||||
|
||||
invis String @unique
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
imageId Int
|
||||
image Image @relation(fields: [imageId], references: [id])
|
||||
}
|
||||
|
||||
model Url {
|
||||
id Int @id @default(autoincrement())
|
||||
to String
|
||||
created_at DateTime @default(now())
|
||||
views Int @default(0)
|
||||
invisible InvisibleUrl?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
id String @id @unique
|
||||
destination String
|
||||
vanity String?
|
||||
created_at DateTime @default(now())
|
||||
views Int @default(0)
|
||||
invisible InvisibleUrl?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
}
|
||||
|
||||
model InvisibleUrl {
|
||||
id Int
|
||||
url Url @relation(fields: [id], references: [id])
|
||||
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
urlId String
|
||||
url Url @relation(fields: [urlId], references: [id])
|
||||
}
|
||||
|
||||
model Stats {
|
||||
id Int @id @default(autoincrement())
|
||||
created_at DateTime @default(now())
|
||||
data Json
|
||||
}
|
||||
@@ -9,8 +9,8 @@ async function main() {
|
||||
username: 'administrator',
|
||||
password: await hashPassword('password'),
|
||||
token: createToken(),
|
||||
administrator: true
|
||||
}
|
||||
administrator: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 279 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 4.8 KiB |
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
branches: ['trunk'],
|
||||
plugins: [
|
||||
'@semantic-release/commit-analyzer',
|
||||
'@semantic-release/github',
|
||||
'@semantic-release/changelog'
|
||||
]
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
const Logger = require('../src/lib/logger');
|
||||
const prismaRun = require('./prisma-run');
|
||||
|
||||
module.exports = async (config) => {
|
||||
try {
|
||||
await prismaRun(config.database.url, ['migrate', 'deploy']);
|
||||
await prismaRun(config.database.url, ['generate']);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
Logger.get('db').error('there was an error.. exiting..');
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
38
scripts/exts.js
Normal file
38
scripts/exts.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// https://github.com/toptal/haste-server/blob/master/static/application.js#L167-L174
|
||||
// Popular extension map
|
||||
module.exports = {
|
||||
rb: 'ruby',
|
||||
py: 'python',
|
||||
pl: 'perl',
|
||||
php: 'php',
|
||||
scala: 'scala',
|
||||
go: 'go',
|
||||
xml: 'xml',
|
||||
html: 'xml',
|
||||
htm: 'xml',
|
||||
css: 'css',
|
||||
js: 'javascript',
|
||||
json: 'json',
|
||||
vbs: 'vbscript',
|
||||
lua: 'lua',
|
||||
pas: 'delphi',
|
||||
java: 'java',
|
||||
cpp: 'cpp',
|
||||
cc: 'cpp',
|
||||
m: 'objectivec',
|
||||
vala: 'vala',
|
||||
sql: 'sql',
|
||||
sm: 'smalltalk',
|
||||
lisp: 'lisp',
|
||||
ini: 'ini',
|
||||
diff: 'diff',
|
||||
bash: 'bash',
|
||||
sh: 'bash',
|
||||
tex: 'tex',
|
||||
erl: 'erlang',
|
||||
hs: 'haskell',
|
||||
md: 'markdown',
|
||||
txt: '',
|
||||
coffee: 'coffee',
|
||||
swift: 'swift',
|
||||
};
|
||||
@@ -11,7 +11,7 @@ const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
await validateConfig(config);
|
||||
|
||||
process.env.DATABASE_URL = config.database.url;
|
||||
process.env.DATABASE_URL = config.core.database_url;
|
||||
|
||||
const files = await readdir(process.argv[2]);
|
||||
const data = files.map(x => {
|
||||
@@ -20,7 +20,7 @@ const { PrismaClient } = require('@prisma/client');
|
||||
return {
|
||||
file: x,
|
||||
mimetype: mime,
|
||||
userId: 1
|
||||
userId: 1,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
Logger.get('migrator').info('starting migrations...');
|
||||
await prisma.image.createMany({
|
||||
data
|
||||
data,
|
||||
});
|
||||
Logger.get('migrator').info('finished migrations! It is recomended to move your old uploads folder (' + process.argv[2] + ') to the current one which is ' + config.uploader.directory);
|
||||
process.exit();
|
||||
|
||||
@@ -74,5 +74,5 @@ module.exports = {
|
||||
'.zip': 'application/zip',
|
||||
'.3gp': 'video/3gpp',
|
||||
'.3g2': 'video/3gpp2',
|
||||
'.7z': 'application/x-7z-compressed'
|
||||
'.7z': 'application/x-7z-compressed',
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
const { spawn } = require('child_process');
|
||||
const { join } = require('path');
|
||||
|
||||
module.exports = (url, args) => {
|
||||
return new Promise((res, rej) => {
|
||||
const proc = spawn(join(process.cwd(), 'node_modules', '.bin', 'prisma'), args, {
|
||||
env: {
|
||||
DATABASE_URL: url,
|
||||
...process.env
|
||||
},
|
||||
});
|
||||
|
||||
let a = '';
|
||||
|
||||
proc.stdout.on('data', d => {
|
||||
console.log(d.toString());
|
||||
a += d.toString();
|
||||
});
|
||||
proc.stderr.on('data', d => {
|
||||
console.log(d.toString());
|
||||
rej(d.toString());
|
||||
});
|
||||
proc.stdout.on('end', () => res(a));
|
||||
proc.stdout.on('close', () => res(a));
|
||||
});
|
||||
};
|
||||
129
server/index.js
129
server/index.js
@@ -1,129 +0,0 @@
|
||||
const next = require('next');
|
||||
const { createServer } = require('http');
|
||||
const { stat, mkdir } = require('fs/promises');
|
||||
const { execSync } = require('child_process');
|
||||
const { extname } = require('path');
|
||||
const { red, green, bold } = require('colorette');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const validateConfig = require('./validateConfig');
|
||||
const Logger = require('../src/lib/logger');
|
||||
const getFile = require('./static');
|
||||
const prismaRun = require('../scripts/prisma-run');
|
||||
const readConfig = require('../src/lib/readConfig');
|
||||
const mimes = require('../scripts/mimes');
|
||||
const deployDb = require('../scripts/deploy-db');
|
||||
|
||||
|
||||
Logger.get('server').info('starting zipline server');
|
||||
|
||||
const dev = process.env.NODE_ENV === 'development';
|
||||
|
||||
function log(url, status) {
|
||||
if (url.startsWith('/_next') || url.startsWith('/__nextjs')) return;
|
||||
return Logger.get('url').info(`${status === 200 ? bold(green(status)) : bold(red(status))}: ${url}`);
|
||||
}
|
||||
|
||||
function shouldUseYarn() {
|
||||
try {
|
||||
execSync('yarnpkg --version', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
await validateConfig(config);
|
||||
|
||||
const data = await prismaRun(config.database.url, ['migrate', 'status']);
|
||||
if (data.includes('Following migration have not yet been applied:')) {
|
||||
Logger.get('database').info('some migrations are not applied, applying them now...');
|
||||
await deployDb(config);
|
||||
Logger.get('database').info('finished applying migrations');
|
||||
}
|
||||
process.env.DATABASE_URL = config.database.url;
|
||||
|
||||
await stat('./.next');
|
||||
await mkdir(config.uploader.directory, { recursive: true });
|
||||
|
||||
const app = next({
|
||||
dir: '.',
|
||||
dev,
|
||||
quiet: dev
|
||||
}, config.core.port, config.core.host);
|
||||
|
||||
await app.prepare();
|
||||
|
||||
const handle = app.getRequestHandler();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const srv = createServer(async (req, res) => {
|
||||
if (req.url.startsWith(config.uploader.route)) {
|
||||
const parts = req.url.split('/');
|
||||
if (!parts[2] || parts[2] === '') return;
|
||||
|
||||
const data = await getFile(config.uploader.directory, parts[2]);
|
||||
if (!data) {
|
||||
app.render404(req, res);
|
||||
} else {
|
||||
let image = await prisma.image.findFirst({
|
||||
where: {
|
||||
OR: {
|
||||
file: parts[2],
|
||||
},
|
||||
OR: {
|
||||
invisible: {
|
||||
invis: decodeURI(parts[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (image) {
|
||||
await prisma.image.update({
|
||||
where: {
|
||||
id: image.id,
|
||||
},
|
||||
data: {
|
||||
views: {
|
||||
increment: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
res.setHeader('Content-Type', image.mimetype);
|
||||
} else {
|
||||
const mimetype = mimes[extname(parts[2])] ?? 'application/octet-stream';
|
||||
res.setHeader('Content-Type', mimetype);
|
||||
}
|
||||
|
||||
res.end(data);
|
||||
}
|
||||
} else {
|
||||
handle(req, res);
|
||||
}
|
||||
|
||||
log(req.url, res.statusCode);
|
||||
});
|
||||
|
||||
srv.on('error', (e) => {
|
||||
Logger.get('server').error(e);
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
srv.on('listening', () => {
|
||||
Logger.get('server').info(`listening on ${config.core.host}:${config.core.port}`);
|
||||
});
|
||||
|
||||
srv.listen(config.core.port, config.core.host ?? '0.0.0.0');
|
||||
} catch (e) {
|
||||
if (e.message && e.message.startsWith('Could not find a production')) {
|
||||
Logger.get('web').error(`there is no production build - run \`${shouldUseYarn() ? 'yarn build' : 'npm build'}\``);
|
||||
} else if (e.code && e.code === 'ENOENT') {
|
||||
if (e.path === './.next') Logger.get('web').error(`there is no production build - run \`${shouldUseYarn() ? 'yarn build' : 'npm build'}\``);
|
||||
} else {
|
||||
Logger.get('server').error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -1,11 +0,0 @@
|
||||
const { readFile } = require('fs/promises');
|
||||
const { join } = require('path');
|
||||
|
||||
module.exports = async (dir, file) => {
|
||||
try {
|
||||
const data = await readFile(join(process.cwd(), dir, file));
|
||||
return data;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
const Logger = require('../src/lib/logger');
|
||||
|
||||
function dot(str, obj) {
|
||||
return str.split('.').reduce((a,b) => a[b], obj);
|
||||
}
|
||||
|
||||
const path = (path, type) => ({ path, type });
|
||||
|
||||
module.exports = async config => {
|
||||
const paths = [
|
||||
path('core.secure', 'boolean'),
|
||||
path('core.secret', 'string'),
|
||||
path('core.host', 'string'),
|
||||
path('core.port', 'number'),
|
||||
path('database.type', 'string'),
|
||||
path('database.url', 'string'),
|
||||
path('uploader.route', 'string'),
|
||||
path('uploader.embed_route', 'string'),
|
||||
path('uploader.length', 'number'),
|
||||
path('uploader.directory', 'string')
|
||||
];
|
||||
|
||||
let errors = 0;
|
||||
|
||||
for (let i = 0, L = paths.length; i !== L; ++i) {
|
||||
const path = paths[i];
|
||||
const value = dot(path.path, config);
|
||||
if (value === undefined) {
|
||||
Logger.get('config').error(`there was no ${path.path} in config`);
|
||||
++errors;
|
||||
}
|
||||
const type = typeof value;
|
||||
|
||||
if (value !== undefined && type !== path.type) {
|
||||
Logger.get('config').error(`expected ${path.type} on ${path.path}, but got ${type}`);
|
||||
++errors;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors !== 0) {
|
||||
Logger.get('config').error(`exiting due to ${errors} errors`);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Snackbar, Alert as MuiAlert } from '@material-ui/core';
|
||||
|
||||
export default function Alert({ open, setOpen, severity, message }) {
|
||||
return (
|
||||
<Snackbar open={open} autoHideDuration={6000} anchorOrigin={{ vertical: 'top', horizontal: 'center' }} onClose={() => setOpen(false)}>
|
||||
<MuiAlert severity={severity} sx={{ width: '100%' }}>
|
||||
{message}
|
||||
</MuiAlert>
|
||||
</Snackbar>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,8 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Backdrop as MuiBackdrop,
|
||||
CircularProgress
|
||||
} from '@material-ui/core';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
|
||||
export default function Backdrop({ open }) {
|
||||
return (
|
||||
<MuiBackdrop
|
||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||
open={open}
|
||||
>
|
||||
<CircularProgress color='inherit' />
|
||||
</MuiBackdrop>
|
||||
<LoadingOverlay visible={open} />
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,16 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card as MuiCard,
|
||||
CardContent,
|
||||
Typography
|
||||
} from '@material-ui/core';
|
||||
Card as MCard,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
|
||||
export default function Card(props) {
|
||||
const { name, children, ...other } = props;
|
||||
|
||||
return (
|
||||
<MuiCard sx={{ minWidth: 100 }} {...other}>
|
||||
<CardContent>
|
||||
<Typography variant='h3'>{name}</Typography>
|
||||
{children}
|
||||
</CardContent>
|
||||
</MuiCard>
|
||||
<MCard padding='md' shadow='sm' {...other}>
|
||||
<Title order={2}>{name}</Title>
|
||||
{children}
|
||||
</MCard>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@material-ui/core';
|
||||
|
||||
export default function CenteredBox({ children, ...other }) {
|
||||
return (
|
||||
<Box
|
||||
justifyContent='center'
|
||||
display='flex'
|
||||
alignItems='center'
|
||||
{...other}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +1,94 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardMedia,
|
||||
CardActionArea,
|
||||
Popover,
|
||||
Button,
|
||||
ButtonGroup
|
||||
} from '@material-ui/core';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import useFetch from '../lib/hooks/useFetch';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { Button, Card, Group, Image as MImage, Modal, Title } from '@mantine/core';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { CopyIcon, Cross1Icon, StarIcon, TrashIcon } from '@modulz/radix-icons';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
|
||||
export default function Image({ image, updateImages }) {
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [t] = useState(image.mimetype.split('/')[0]);
|
||||
const notif = useNotifications();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await useFetch('/api/user/images', 'DELETE', { id: image.id });
|
||||
if (!res.error) updateImages();
|
||||
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
|
||||
if (!res.error) {
|
||||
updateImages(true);
|
||||
notif.showNotification({
|
||||
title: 'Image Deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <TrashIcon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'Failed to delete image',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
}
|
||||
|
||||
setAnchorEl(null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
copy(`${window.location.protocol}//${window.location.host}${image.url}`);
|
||||
setAnchorEl(null);
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${image.url}`);
|
||||
setOpen(false);
|
||||
notif.showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
const data = await useFetch('/api/user/files', 'PATCH', { id: image.id, favorite: !image.favorite });
|
||||
if (!data.error) updateImages(true);
|
||||
notif.showNotification({
|
||||
title: 'Image is now ' + (!image.favorite ? 'favorited' : 'unfavorited'),
|
||||
message: '',
|
||||
icon: <StarIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const Type = (props) => {
|
||||
return {
|
||||
'video': <video controls {...props} />,
|
||||
'image': <MImage {...props} />,
|
||||
'audio': <audio controls {...props} />,
|
||||
}[t];
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card sx={{ maxWidth: '100%' }}>
|
||||
<CardActionArea>
|
||||
<CardMedia
|
||||
sx={{ height: 320 }}
|
||||
image={image.url}
|
||||
title={image.file}
|
||||
onClick={e => setAnchorEl(e.currentTarget)}
|
||||
/>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Title>{image.file}</Title>}
|
||||
>
|
||||
<ButtonGroup variant='contained'>
|
||||
<Button onClick={handleDelete} color='primary'>Delete</Button>
|
||||
<Button onClick={handleCopy} color='primary'>Copy URL</Button>
|
||||
</ButtonGroup>
|
||||
</Popover>
|
||||
<Type
|
||||
src={image.url}
|
||||
alt={image.file}
|
||||
/>
|
||||
<Group position='right' mt={22}>
|
||||
<Button onClick={handleCopy}>Copy</Button>
|
||||
<Button onClick={handleDelete}>Delete</Button>
|
||||
<Button onClick={handleFavorite}>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
|
||||
<Card.Section>
|
||||
<Type
|
||||
sx={{ maxHeight: 320, fontSize: 70, width: '100%', cursor: 'pointer' }}
|
||||
style={{ maxHeight: 320, fontSize: 70, width: '100%', cursor: 'pointer' }}
|
||||
src={image.url}
|
||||
alt={image.file}
|
||||
onClick={() => setOpen(true)}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
158
src/components/ImagesTable.tsx
Normal file
158
src/components/ImagesTable.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/* eslint-disable react/jsx-key */
|
||||
/* eslint-disable react/display-name */
|
||||
// Code taken from https://codesandbox.io/s/eojw8 and is modified a bit (the entire src/components/table directory)
|
||||
import React from 'react';
|
||||
import {
|
||||
usePagination,
|
||||
useTable,
|
||||
} from 'react-table';
|
||||
import {
|
||||
ActionIcon,
|
||||
createStyles,
|
||||
Divider,
|
||||
Group,
|
||||
Pagination,
|
||||
Select,
|
||||
Table,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
CopyIcon,
|
||||
EnterIcon,
|
||||
TrashIcon,
|
||||
} from '@modulz/radix-icons';
|
||||
|
||||
const pageSizeOptions = ['10', '25', '50'];
|
||||
|
||||
const useStyles = createStyles((t) => ({
|
||||
root: { height: '100%', display: 'block', marginTop: 10 },
|
||||
tableContainer: {
|
||||
display: 'block',
|
||||
overflow: 'auto',
|
||||
'& > table': {
|
||||
'& > thead': { backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0], zIndex: 1 },
|
||||
'& > thead > tr > th': { padding: t.spacing.md },
|
||||
'& > tbody > tr > td': { padding: t.spacing.md },
|
||||
},
|
||||
borderRadius: 6,
|
||||
},
|
||||
stickHeader: { top: 0, position: 'sticky' },
|
||||
disableSortIcon: { color: t.colors.gray[5] },
|
||||
sortDirectionIcon: { transition: 'transform 200ms ease' },
|
||||
}));
|
||||
|
||||
export default function ImagesTable({
|
||||
columns,
|
||||
data = [],
|
||||
serverSideDataSource = false,
|
||||
initialPageSize = 10,
|
||||
initialPageIndex = 0,
|
||||
pageCount = 0,
|
||||
total = 0,
|
||||
deleteImage, copyImage, viewImage,
|
||||
}) {
|
||||
const { classes } = useStyles();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const tableOptions = useTable(
|
||||
{
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
initialState: { pageSize: initialPageSize, pageIndex: initialPageIndex },
|
||||
},
|
||||
usePagination
|
||||
);
|
||||
|
||||
const {
|
||||
getTableProps, getTableBodyProps, headerGroups, rows, prepareRow, page, gotoPage, setPageSize, state: { pageIndex, pageSize },
|
||||
} = tableOptions;
|
||||
|
||||
const getPageRecordInfo = () => {
|
||||
const firstRowNum = pageIndex * pageSize + 1;
|
||||
const totalRows = serverSideDataSource ? total : rows.length;
|
||||
|
||||
const currLastRowNum = (pageIndex + 1) * pageSize;
|
||||
let lastRowNum = currLastRowNum < totalRows ? currLastRowNum : totalRows;
|
||||
return `${firstRowNum} - ${lastRowNum} of ${totalRows}`;
|
||||
};
|
||||
|
||||
const getPageCount = () => {
|
||||
const totalRows = serverSideDataSource ? total : rows.length;
|
||||
return Math.ceil(totalRows / pageSize);
|
||||
};
|
||||
|
||||
const handlePageChange = (pageNum) => gotoPage(pageNum - 1);
|
||||
|
||||
const renderHeader = () => headerGroups.map(hg => (
|
||||
<tr {...hg.getHeaderGroupProps()}>
|
||||
{hg.headers.map(column => (
|
||||
<th {...column.getHeaderProps()}>
|
||||
<Group noWrap position={column.align || 'apart'}>
|
||||
<div>{column.render('Header')}</div>
|
||||
</Group>
|
||||
</th>
|
||||
))}
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
));
|
||||
|
||||
const renderRow = rows => rows.map(row => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
<tr {...row.getRowProps()}>
|
||||
{row.cells.map(cell => (
|
||||
<td align={cell.column.align || 'left'} {...cell.getCellProps()}>
|
||||
{cell.render('Cell')}
|
||||
</td>
|
||||
))}
|
||||
<td align='right'>
|
||||
<Group noWrap>
|
||||
<ActionIcon color='red' variant='outline' onClick={() => deleteImage(row)}><TrashIcon /></ActionIcon>
|
||||
<ActionIcon color='primary' variant='outline' onClick={() => copyImage(row)}><CopyIcon /></ActionIcon>
|
||||
<ActionIcon color='green' variant='outline' onClick={() => viewImage(row)}><EnterIcon /></ActionIcon>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div
|
||||
className={classes.tableContainer}
|
||||
style={{ height: 'calc(100% - 44px)' }}
|
||||
>
|
||||
<Table {...getTableProps()}>
|
||||
<thead style={{ backgroundColor: theme.other.hover }}>
|
||||
{renderHeader()}
|
||||
</thead>
|
||||
|
||||
<tbody {...getTableBodyProps()}>
|
||||
{renderRow(page)}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<Divider mb='md' variant='dotted' />
|
||||
<Group position='left'>
|
||||
<Text size='sm'>Rows per page: </Text>
|
||||
<Select
|
||||
style={{ width: '72px' }}
|
||||
variant='filled'
|
||||
data={pageSizeOptions}
|
||||
value={pageSize + ''}
|
||||
onChange={pageSize => setPageSize(Number(pageSize))} />
|
||||
<Divider orientation='vertical' />
|
||||
|
||||
<Text size='sm'>{getPageRecordInfo()}</Text>
|
||||
<Divider orientation='vertical' />
|
||||
|
||||
<Pagination
|
||||
page={pageIndex + 1}
|
||||
total={getPageCount()}
|
||||
onChange={handlePageChange} />
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,417 +1,329 @@
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import useFetch from '../lib/hooks/useFetch';
|
||||
|
||||
import {
|
||||
AppBar,
|
||||
Box,
|
||||
Divider,
|
||||
Drawer,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Select,
|
||||
} from '@material-ui/core';
|
||||
import {
|
||||
Menu as MenuIcon,
|
||||
Home as HomeIcon,
|
||||
AccountCircle as AccountIcon,
|
||||
Image as ImageIcon,
|
||||
Upload as UploadIcon,
|
||||
ContentCopy as CopyIcon,
|
||||
Autorenew as ResetIcon,
|
||||
Logout as LogoutIcon,
|
||||
PeopleAlt as UsersIcon,
|
||||
Brush as BrushIcon
|
||||
} from '@material-ui/icons';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import Backdrop from './Backdrop';
|
||||
import { friendlyThemeName, themes } from './Theming';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useStoreDispatch } from 'lib/redux/store';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { CheckIcon, CopyIcon, Cross1Icon, FileIcon, GearIcon, HomeIcon, Link1Icon, ResetIcon, UploadIcon, PinRightIcon, PersonIcon, Pencil1Icon, MixerHorizontalIcon } from '@modulz/radix-icons';
|
||||
import { AppShell, Burger, Divider, Group, Header, MediaQuery, Navbar, Paper, Popover, ScrollArea, Select, Text, ThemeIcon, Title, UnstyledButton, useMantineTheme, Box } from '@mantine/core';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { friendlyThemeName, themes } from './Theming';
|
||||
|
||||
function MenuItemLink(props) {
|
||||
return (
|
||||
<Link href={props.href} passHref>
|
||||
<MenuItem {...props} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem(props) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
sx={theme => ({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: 5,
|
||||
borderRadius: theme.radius.sm,
|
||||
color: props.color
|
||||
? theme.fn.themeColor(props.color, theme.colorScheme === 'dark' ? 5 : 7)
|
||||
: theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[0]
|
||||
: theme.black,
|
||||
'&:hover': {
|
||||
backgroundColor: props.color
|
||||
? theme.fn.rgba(
|
||||
theme.fn.themeColor(props.color, theme.colorScheme === 'dark' ? 9 : 0),
|
||||
theme.colorScheme === 'dark' ? 0.2 : 1
|
||||
)
|
||||
: theme.colorScheme === 'dark'
|
||||
? theme.fn.rgba(theme.colors.dark[3], 0.35)
|
||||
: theme.colors.gray[0],
|
||||
},
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
<Group noWrap>
|
||||
<Box sx={theme => ({
|
||||
marginRight: theme.spacing.xs / 4,
|
||||
paddingLeft: theme.spacing.xs / 2,
|
||||
|
||||
'& *': {
|
||||
display: 'block',
|
||||
},
|
||||
})}>
|
||||
{props.icon}
|
||||
</Box>
|
||||
<Text size='sm'>{props.children}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
const items = [
|
||||
{
|
||||
icon: <HomeIcon />,
|
||||
text: 'Home',
|
||||
link: '/dashboard'
|
||||
link: '/dashboard',
|
||||
},
|
||||
{
|
||||
icon: <ImageIcon />,
|
||||
text: 'Images',
|
||||
link: '/dashboard/images'
|
||||
icon: <FileIcon />,
|
||||
text: 'Files',
|
||||
link: '/dashboard/files',
|
||||
},
|
||||
{
|
||||
icon: <MixerHorizontalIcon />,
|
||||
text: 'Stats',
|
||||
link: '/dashboard/stats',
|
||||
},
|
||||
{
|
||||
icon: <Link1Icon />,
|
||||
text: 'URLs',
|
||||
link: '/dashboard/urls',
|
||||
},
|
||||
{
|
||||
icon: <UploadIcon />,
|
||||
text: 'Upload',
|
||||
link: '/dashboard/upload'
|
||||
}
|
||||
link: '/dashboard/upload',
|
||||
},
|
||||
];
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
function CopyTokenDialog({ open, setOpen, token }) {
|
||||
const handleCopyToken = () => {
|
||||
copy(token);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<DialogTitle id='copy-dialog-title'>
|
||||
Copy Token
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id='copy-dialog-description'>
|
||||
Make sure you don't share this token with anyone as they will be able to upload images on your behalf.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
|
||||
<Button onClick={handleCopyToken} color='inherit'>
|
||||
Copy
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetTokenDialog({ open, setOpen, setToken }) {
|
||||
const handleResetToken = async () => {
|
||||
const a = await useFetch('/api/user/token', 'PATCH');
|
||||
if (a.success) setToken(a.success);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<DialogTitle id='reset-dialog-title'>
|
||||
Reset Token
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id='reset-dialog-description'>
|
||||
Once you reset your token, you will have to update any uploaders to use this new token.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
|
||||
<Button onClick={handleResetToken} color='inherit'>
|
||||
Reset
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout({ children, user, loading, noPaper }) {
|
||||
const [systemTheme, setSystemTheme] = useState(user.systemTheme || 'dark_blue');
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [copyOpen, setCopyOpen] = useState(false);
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
export default function Layout({ children, user }) {
|
||||
const [token, setToken] = useState(user?.token);
|
||||
const [systemTheme, setSystemTheme] = useState(user.systemTheme ?? 'system');
|
||||
const [opened, setOpened] = useState(false); // navigation open
|
||||
const [open, setOpen] = useState(false); // manage acc dropdown
|
||||
const router = useRouter();
|
||||
const dispatch = useStoreDispatch();
|
||||
const theme = useMantineTheme();
|
||||
const modals = useModals();
|
||||
const notif = useNotifications();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = e => setAnchorEl(e.currentTarget);
|
||||
const handleClose = (cmd: 'copy' | 'reset') => () => {
|
||||
switch (cmd) {
|
||||
case 'copy':
|
||||
setCopyOpen(true);
|
||||
break;
|
||||
case 'reset':
|
||||
setResetOpen(true);
|
||||
break;
|
||||
}
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleUpdateTheme = async (event: React.ChangeEvent<{ value: string }>) => {
|
||||
const handleUpdateTheme = async value => {
|
||||
const newUser = await useFetch('/api/user', 'PATCH', {
|
||||
systemTheme: event.target.value || 'dark_blue'
|
||||
systemTheme: value || 'dark_blue',
|
||||
});
|
||||
|
||||
setSystemTheme(newUser.systemTheme);
|
||||
dispatch(updateUser(newUser));
|
||||
|
||||
router.replace(router.pathname);
|
||||
|
||||
notif.showNotification({
|
||||
title: `Theme changed to ${friendlyThemeName[value]}`,
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <Pencil1Icon />,
|
||||
});
|
||||
};
|
||||
|
||||
const drawer = (
|
||||
<div>
|
||||
<CopyTokenDialog open={copyOpen} setOpen={setCopyOpen} token={token} />
|
||||
<ResetTokenDialog open={resetOpen} setOpen={setResetOpen} setToken={setToken} />
|
||||
<Toolbar
|
||||
sx={{
|
||||
width: { xs: drawerWidth }
|
||||
}}
|
||||
>
|
||||
<AppBar
|
||||
position='fixed'
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderBottomColor: t => t.palette.divider,
|
||||
display: { xs: 'none', sm: 'block' }
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color='inherit'
|
||||
aria-label='open drawer'
|
||||
edge='start'
|
||||
onClick={() => setMobileOpen(true)}
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography
|
||||
variant='h5'
|
||||
noWrap
|
||||
component='div'
|
||||
>
|
||||
Zipline
|
||||
</Typography>
|
||||
{user && (
|
||||
<Box sx={{ marginLeft: 'auto' }}>
|
||||
<Button
|
||||
color='inherit'
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<AccountIcon />
|
||||
</Button>
|
||||
<Menu
|
||||
id='zipline-user-menu'
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose(null)}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
<MenuItem disableRipple>
|
||||
<Typography variant='h5'>
|
||||
<b>{user.username}</b>
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<Link href='/dashboard/manage'>
|
||||
<a style={{ color: 'white', textDecoration: 'none' }}>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<AccountIcon sx={{ mr: 2 }} /> Manage Account
|
||||
</MenuItem>
|
||||
</a>
|
||||
</Link>
|
||||
<MenuItem onClick={handleClose('copy')}>
|
||||
<CopyIcon sx={{ mr: 2 }} /> Copy Token
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleClose('reset')}>
|
||||
<ResetIcon sx={{ mr: 2 }} /> Reset Token
|
||||
</MenuItem>
|
||||
<Link href='/auth/logout'>
|
||||
<a style={{ color: 'white', textDecoration: 'none' }}>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<LogoutIcon sx={{ mr: 2 }} /> Logout
|
||||
</MenuItem>
|
||||
</a>
|
||||
</Link>
|
||||
<MenuItem>
|
||||
<BrushIcon sx={{ mr: 2 }} />
|
||||
<Select
|
||||
variant='standard'
|
||||
label='Theme'
|
||||
value={systemTheme}
|
||||
onChange={handleUpdateTheme}
|
||||
fullWidth
|
||||
>
|
||||
{Object.keys(themes).map(t => (
|
||||
<MenuItem value={t} key={t}>
|
||||
{friendlyThemeName[t]}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<List>
|
||||
{items.map((item, i) => (
|
||||
<Link key={i} href={item.link}>
|
||||
<a href={item.link} style={{ color: 'white', textDecoration: 'none' }}>
|
||||
<ListItem button>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItem>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
{user && user.administrator && (
|
||||
<Link href='/dashboard/users' passHref>
|
||||
<a style={{ color: 'white', textDecoration: 'none' }}>
|
||||
<ListItem button>
|
||||
<ListItemIcon><UsersIcon /></ListItemIcon>
|
||||
<ListItemText primary='Users' />
|
||||
</ListItem>
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</List>
|
||||
|
||||
</div>
|
||||
);
|
||||
const openResetToken = () => modals.openConfirmModal({
|
||||
title: 'Reset Token',
|
||||
children: (
|
||||
<Text size='sm'>
|
||||
Once you reset your token, you will have to update any uploaders to use this new token.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: 'Reset', cancel: 'Cancel' },
|
||||
onConfirm: async () => {
|
||||
const a = await useFetch('/api/user/token', 'PATCH');
|
||||
if (!a.success) {
|
||||
setToken(a.success);
|
||||
notif.showNotification({
|
||||
title: 'Token Reset Failed',
|
||||
message: a.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'Token Reset',
|
||||
message: 'Your token has been reset. You will need to update any uploaders to use this new token.',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
const container = typeof window !== 'undefined' ? window.document.body : undefined;
|
||||
modals.closeAll();
|
||||
},
|
||||
});
|
||||
|
||||
const openCopyToken = () => modals.openConfirmModal({
|
||||
title: 'Copy Token',
|
||||
children: (
|
||||
<Text size='sm'>
|
||||
Make sure you don't share this token with anyone as they will be able to upload files on your behalf.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: 'Copy', cancel: 'Cancel' },
|
||||
onConfirm: async () => {
|
||||
clipboard.copy(token);
|
||||
|
||||
notif.showNotification({
|
||||
title: 'Token Copied',
|
||||
message: 'Your token has been copied to your clipboard.',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
});
|
||||
|
||||
modals.closeAll();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<Backdrop open={loading} />
|
||||
<AppShell
|
||||
navbarOffsetBreakpoint='sm'
|
||||
fixed
|
||||
navbar={
|
||||
<Navbar
|
||||
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,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.other.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group>
|
||||
<ThemeIcon color='primary' variant='filled'>
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
|
||||
<AppBar
|
||||
position='fixed'
|
||||
elevation={0}
|
||||
sx={{
|
||||
width: { sm: `calc(100% - ${drawerWidth}px)` },
|
||||
ml: { sm: `${drawerWidth}px` }
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color='inherit'
|
||||
aria-label='open drawer'
|
||||
edge='start'
|
||||
onClick={() => setMobileOpen(true)}
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography
|
||||
variant='h5'
|
||||
noWrap
|
||||
component='div'
|
||||
sx={{ display: { sm: 'none' } }}
|
||||
>
|
||||
Zipline
|
||||
</Typography>
|
||||
{user && (
|
||||
<Box sx={{ marginLeft: 'auto' }}>
|
||||
<Button
|
||||
color='inherit'
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
<Text size='lg'>{text}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Link>
|
||||
))}
|
||||
{user.administrator && (
|
||||
<Link href='/dashboard/users' passHref>
|
||||
<UnstyledButton
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: theme.spacing.xs,
|
||||
borderRadius: theme.radius.sm,
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.black,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.other.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group>
|
||||
<ThemeIcon color='primary' variant='filled'>
|
||||
<PersonIcon />
|
||||
</ThemeIcon>
|
||||
|
||||
<Text size='lg'>Users</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Link>
|
||||
)}
|
||||
</Navbar.Section>
|
||||
</Navbar>
|
||||
}
|
||||
header={
|
||||
<Header height={70} 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={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: theme.spacing.xs,
|
||||
borderRadius: theme.radius.sm,
|
||||
color: theme.other.color,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.other.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group>
|
||||
<ThemeIcon color='primary' variant='filled'>
|
||||
<GearIcon />
|
||||
</ThemeIcon>
|
||||
<Text>{user.username}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
}
|
||||
>
|
||||
<AccountIcon />
|
||||
</Button>
|
||||
<Menu
|
||||
id='zipline-user-menu'
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose(null)}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
<MenuItem disableRipple>
|
||||
<Typography variant='h5'>
|
||||
<b>{user.username}</b>
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<Link href='/dash/manage'>
|
||||
<a style={{ color: 'white', textDecoration: 'none' }}>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<AccountIcon sx={{ mr: 2 }} /> Manage Account
|
||||
</MenuItem>
|
||||
</a>
|
||||
</Link>
|
||||
<MenuItem onClick={handleClose('copy')}>
|
||||
<CopyIcon sx={{ mr: 2 }} /> Copy Token
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleClose('reset')}>
|
||||
<ResetIcon sx={{ mr: 2 }} /> Reset Token
|
||||
</MenuItem>
|
||||
<Link href='/auth/logout'>
|
||||
<a style={{ color: 'white', textDecoration: 'none' }}>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<LogoutIcon sx={{ mr: 2 }} /> Logout
|
||||
</MenuItem>
|
||||
</a>
|
||||
</Link>
|
||||
</Menu>
|
||||
<Group direction='column' spacing={2}>
|
||||
<Text sx={{
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6],
|
||||
fontWeight: 500,
|
||||
fontSize: theme.fontSizes.xs,
|
||||
padding: `${theme.spacing.xs / 2}px ${theme.spacing.sm}px`,
|
||||
cursor: 'default',
|
||||
}}>User: {user.username}</Text>
|
||||
<MenuItemLink icon={<GearIcon />} href='/dashboard/manage'>Manage Account</MenuItemLink>
|
||||
<MenuItem icon={<CopyIcon />} onClick={() => {setOpen(false);openCopyToken();}}>Copy Token</MenuItem>
|
||||
<MenuItem icon={<ResetIcon />} onClick={() => {setOpen(false);openResetToken();}} color='red'>Reset Token</MenuItem>
|
||||
<MenuItemLink icon={<PinRightIcon />} href='/auth/logout' color='red'>Logout</MenuItemLink>
|
||||
<Divider
|
||||
variant='solid'
|
||||
my={theme.spacing.xs / 2}
|
||||
sx={theme => ({
|
||||
width: '110%',
|
||||
borderTopColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[2],
|
||||
margin: `${theme.spacing.xs / 2}px -4px`,
|
||||
})}
|
||||
/>
|
||||
<MenuItem icon={<Pencil1Icon />}>
|
||||
<Select
|
||||
size='xs'
|
||||
data={Object.keys(themes).map(t => ({ value: t, label: friendlyThemeName[t] }))}
|
||||
value={systemTheme}
|
||||
onChange={handleUpdateTheme}
|
||||
/>
|
||||
</MenuItem>
|
||||
</Group>
|
||||
</Popover>
|
||||
</Box>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Box
|
||||
component='nav'
|
||||
sx={{
|
||||
width: { sm: drawerWidth },
|
||||
flexShrink: { sm: 0 }
|
||||
}}
|
||||
>
|
||||
<Drawer
|
||||
container={container}
|
||||
variant='temporary'
|
||||
onClose={() => setMobileOpen(false)}
|
||||
open={mobileOpen}
|
||||
elevation={0}
|
||||
ModalProps={{
|
||||
keepMounted: true
|
||||
}}
|
||||
sx={{
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth }
|
||||
}}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
<Drawer
|
||||
variant='permanent'
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth }
|
||||
}}
|
||||
open
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Box>
|
||||
<Box component='main' sx={{ flexGrow: 1, p: 3, mt: 8 }}>
|
||||
{user && noPaper ? children : (
|
||||
<Paper elevation={0} sx={{ p: 2 }} variant='outlined'>
|
||||
{children}
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</div>
|
||||
</Header>
|
||||
}
|
||||
>
|
||||
<Paper withBorder padding='md' shadow='xs'>{children}</Paper>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { forwardRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useRouter } from 'next/router';
|
||||
import NextLink from 'next/link';
|
||||
import MuiLink from '@material-ui/core/Link';
|
||||
import { Text } from '@mantine/core';
|
||||
|
||||
export const NextLinkComposed = forwardRef(function NextLinkComposed(props: any, ref) {
|
||||
const { to, linkAs, href, replace, scroll, passHref, shallow, prefetch, locale, ...other } =
|
||||
@@ -50,10 +50,10 @@ const Link = forwardRef(function Link(props: any, ref) {
|
||||
|
||||
if (isExternal) {
|
||||
if (noLinkStyle) {
|
||||
return <a className={className} href={href} ref={ref} {...other} />;
|
||||
return <Text variant='link' component='a' className={className} href={href} ref={ref} {...other} />;
|
||||
}
|
||||
|
||||
return <MuiLink className={className} href={href} ref={ref} {...other} />;
|
||||
return <Text component='a' variant='link' href={href} ref={ref} {...other} />;
|
||||
}
|
||||
|
||||
if (noLinkStyle) {
|
||||
@@ -61,8 +61,9 @@ const Link = forwardRef(function Link(props: any, ref) {
|
||||
}
|
||||
|
||||
return (
|
||||
<MuiLink
|
||||
<Text
|
||||
component={NextLinkComposed}
|
||||
variant='link'
|
||||
linkAs={linkAs}
|
||||
className={className}
|
||||
ref={ref}
|
||||
|
||||
6
src/components/StatText.tsx
Normal file
6
src/components/StatText.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Text } from '@mantine/core';
|
||||
|
||||
export default function StatText({ children }) {
|
||||
return <Text color='gray' size='xl'>{children}</Text>;
|
||||
}
|
||||
@@ -1,65 +1,96 @@
|
||||
import React from 'react';
|
||||
import { ThemeProvider } from '@emotion/react';
|
||||
import { CssBaseline } from '@material-ui/core';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
// themes
|
||||
import dark_blue from 'lib/themes/dark_blue';
|
||||
import light_blue from 'lib/themes/light_blue';
|
||||
import dark from 'lib/themes/dark';
|
||||
import ayu_dark from 'lib/themes/ayu_dark';
|
||||
import ayu_mirage from 'lib/themes/ayu_mirage';
|
||||
import ayu_light from 'lib/themes/ayu_light';
|
||||
import nord from 'lib/themes/nord';
|
||||
import polar from 'lib/themes/polar';
|
||||
import dracula from 'lib/themes/dracula';
|
||||
import matcha_dark_azul from 'lib/themes/matcha_dark_azul';
|
||||
import qogir_dark from 'lib/themes/qogir_dark';
|
||||
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import createTheme from 'lib/themes';
|
||||
import { MantineProvider, MantineThemeOverride } from '@mantine/core';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import { NotificationsProvider } from '@mantine/notifications';
|
||||
import { useColorScheme } from '@mantine/hooks';
|
||||
|
||||
export const themes = {
|
||||
'dark_blue': dark_blue,
|
||||
'dark': dark,
|
||||
'ayu_dark': ayu_dark,
|
||||
'ayu_mirage': ayu_mirage,
|
||||
'ayu_light': ayu_light,
|
||||
'nord': nord,
|
||||
'polar': polar
|
||||
system: (colorScheme: 'dark' | 'light') => colorScheme === 'dark' ? dark_blue : light_blue,
|
||||
dark_blue,
|
||||
light_blue,
|
||||
dark,
|
||||
ayu_dark,
|
||||
ayu_mirage,
|
||||
ayu_light,
|
||||
nord,
|
||||
dracula,
|
||||
matcha_dark_azul,
|
||||
qogir_dark,
|
||||
};
|
||||
|
||||
export const friendlyThemeName = {
|
||||
'system': 'System Theme',
|
||||
'dark_blue': 'Dark Blue',
|
||||
'light_blue': 'Light Blue',
|
||||
'dark': 'Very Dark',
|
||||
'ayu_dark': 'Ayu Dark',
|
||||
'ayu_mirage': 'Ayu Mirage',
|
||||
'ayu_light': 'Ayu Light',
|
||||
'nord': 'Nord',
|
||||
'polar': 'Polar'
|
||||
'dracula': 'Dracula',
|
||||
'matcha_dark_azul': 'Matcha Dark Azul',
|
||||
'qogir_dark': 'Qogir Dark',
|
||||
};
|
||||
|
||||
export default function ZiplineTheming({ Component, pageProps }) {
|
||||
let t;
|
||||
|
||||
export default function ZiplineTheming({ Component, pageProps, ...props }) {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
if (!user) t = themes.dark_blue;
|
||||
else {
|
||||
if (user.customTheme) {
|
||||
t = createTheme({
|
||||
type: 'dark',
|
||||
primary: user.customTheme.primary,
|
||||
secondary: user.customTheme.secondary,
|
||||
error: user.customTheme.error,
|
||||
warning: user.customTheme.warning,
|
||||
info: user.customTheme.info,
|
||||
border: user.customTheme.border,
|
||||
background: {
|
||||
main: user.customTheme.mainBackground,
|
||||
paper: user.customTheme.paperBackground
|
||||
}
|
||||
});
|
||||
} else {
|
||||
t = themes[user.systemTheme] ?? themes.dark_blue;
|
||||
}
|
||||
}
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
let theme: MantineThemeOverride;
|
||||
|
||||
if (!user) theme = themes.system(colorScheme);
|
||||
else if (user.systemTheme === 'system') theme = themes.system(colorScheme);
|
||||
else theme = themes[user.systemTheme] ?? themes.system(colorScheme);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty('color-scheme', theme.colorScheme);
|
||||
}, [user, theme]);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={t}>
|
||||
<CssBaseline />
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
theme={theme}
|
||||
styles={{
|
||||
AppShell: t => ({
|
||||
root: {
|
||||
backgroundColor: t.other.AppShell_backgroundColor,
|
||||
},
|
||||
}),
|
||||
Popover: {
|
||||
inner: {
|
||||
width: 200,
|
||||
},
|
||||
},
|
||||
Accordion: {
|
||||
itemTitle: {
|
||||
border: 0,
|
||||
},
|
||||
itemOpened: {
|
||||
border: 0,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ModalsProvider>
|
||||
<NotificationsProvider>
|
||||
{props.children ? props.children : <Component {...pageProps} />}
|
||||
</NotificationsProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,16 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TablePagination,
|
||||
TableRow,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Typography,
|
||||
Grid
|
||||
} from '@material-ui/core';
|
||||
|
||||
import Link from 'components/Link';
|
||||
import Card from 'components/Card';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import ZiplineImage from 'components/Image';
|
||||
import ImagesTable from 'components/ImagesTable';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import { Text, Skeleton, Title, SimpleGrid } from '@mantine/core';
|
||||
import { randomId, useClipboard } from '@mantine/hooks';
|
||||
import Link from 'components/Link';
|
||||
import { CopyIcon, Cross1Icon, TrashIcon } from '@modulz/radix-icons';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import StatText from 'components/StatText';
|
||||
|
||||
type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify';
|
||||
|
||||
@@ -36,84 +28,56 @@ export function bytesToRead(bytes: number) {
|
||||
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ id: 'file', label: 'Name', minWidth: 170, align: 'inherit' as Aligns },
|
||||
{ id: 'mimetype', label: 'Type', minWidth: 100, align: 'inherit' as Aligns },
|
||||
{
|
||||
id: 'created_at',
|
||||
label: 'Date',
|
||||
minWidth: 170,
|
||||
align: 'right' as Aligns,
|
||||
format: (value) => new Date(value).toLocaleString(),
|
||||
}
|
||||
];
|
||||
|
||||
function StatText({ children }) {
|
||||
return <Typography variant='h5' color='GrayText'>{children}</Typography>;
|
||||
}
|
||||
|
||||
function StatTable({ rows, columns }) {
|
||||
return (
|
||||
<TableContainer sx={{ pt: 1 }}>
|
||||
<Table sx={{ minWidth: 100 }} size='small'>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columns.map(col => (
|
||||
<TableCell key={col.name}>{col.name}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row, i) => (
|
||||
<TableRow
|
||||
key={row.username}
|
||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||
>
|
||||
{columns.map(col => (
|
||||
<TableCell key={col.id}>
|
||||
{col.format ? col.format(row[col.id]) : row[col.id]}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
|
||||
const [images, setImages] = useState([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [recent, setRecent] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [apiLoading, setApiLoading] = useState(true);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const clipboard = useClipboard();
|
||||
const notif = useNotifications();
|
||||
|
||||
const updateImages = async () => {
|
||||
setApiLoading(true);
|
||||
|
||||
const imgs = await useFetch('/api/user/images');
|
||||
const imgs = await useFetch('/api/user/files');
|
||||
const recent = await useFetch('/api/user/recent?filter=media');
|
||||
const stts = await useFetch('/api/stats');
|
||||
setImages(imgs);
|
||||
setStats(stts);console.log(stts);
|
||||
|
||||
setApiLoading(false);
|
||||
setImages(imgs.map(x => ({ ...x, created_at: new Date(x.created_at).toLocaleString() })));
|
||||
setStats(stts);
|
||||
setRecent(recent);
|
||||
};
|
||||
|
||||
const handleChangePage = (event, newPage) => {
|
||||
setPage(newPage);
|
||||
const deleteImage = async ({ original }) => {
|
||||
const res = await useFetch('/api/user/files', 'DELETE', { id: original.id });
|
||||
if (!res.error) {
|
||||
updateImages();
|
||||
notif.showNotification({
|
||||
title: 'Image Deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <TrashIcon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'Failed to delete image',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(+event.target.value);
|
||||
setPage(0);
|
||||
const copyImage = async ({ original }) => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${original.url}`);
|
||||
notif.showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async image => {
|
||||
const res = await useFetch('/api/user/images', 'DELETE', { id: image.id });
|
||||
if (!res.error) updateImages();
|
||||
const viewImage = async ({ original }) => {
|
||||
window.open(`${window.location.protocol}//${window.location.host}${original.url}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -122,113 +86,89 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={apiLoading} />
|
||||
<Typography variant='h4'>Welcome back {user?.username}</Typography>
|
||||
<Typography color='GrayText' pb={2}>You have <b>{images.length}</b> images</Typography>
|
||||
<Title>Welcome back {user?.username}</Title>
|
||||
<Text color='gray' sx={{ paddingBottom: 4 }}>You have <b>{images.length ? images.length : '...'}</b> files</Text>
|
||||
|
||||
<Typography variant='h4'>Stats</Typography>
|
||||
{stats && (
|
||||
<Grid container spacing={4} py={2}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card name='Size' sx={{ height: '100%' }}>
|
||||
<StatText>{stats.size}</StatText>
|
||||
<Typography variant='h3'>Average Size</Typography>
|
||||
<StatText>{bytesToRead(stats.size_num / stats.count)}</StatText>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card name='Images' sx={{ height: '100%' }}>
|
||||
<StatText>{stats.count}</StatText>
|
||||
<Typography variant='h3'>Views</Typography>
|
||||
<StatText>{stats.views_count} ({isNaN(stats.views_count / stats.count) ? '0' : stats.views_count / stats.count})</StatText>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card name='Users' sx={{ height: '100%' }}>
|
||||
<StatText>{stats.count_users}</StatText>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
<Title>Recent Files</Title>
|
||||
<SimpleGrid
|
||||
cols={4}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{recent.length ? recent.map(image => (
|
||||
<ZiplineImage key={randomId()} image={image} updateImages={updateImages} />
|
||||
)) : [1,2,3,4].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Card name='Images' sx={{ my: 2 }} elevation={0} variant='outlined'>
|
||||
<Link href='/dashboard/images' pb={2}>View Gallery</Link>
|
||||
<TableContainer sx={{ maxHeight: 440 }}>
|
||||
<Table size='small'>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableCell
|
||||
key={column.id}
|
||||
align={column.align}
|
||||
sx={{ minWidth: column.minWidth }}
|
||||
>
|
||||
{column.label}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell sx={{ minWidth: 200 }} align='right'>
|
||||
Actions
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{images
|
||||
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
|
||||
.map((row) => {
|
||||
return (
|
||||
<TableRow hover role='checkbox' tabIndex={-1} key={row.id}>
|
||||
{columns.map((column) => {
|
||||
const value = row[column.id];
|
||||
return (
|
||||
<TableCell key={column.id} align={column.align}>
|
||||
{column.format ? column.format(value) : value}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
<TableCell align='right'>
|
||||
<ButtonGroup variant='outlined'>
|
||||
<Button onClick={() => handleDelete(row)} color='error' size='small'>Delete</Button>
|
||||
</ButtonGroup>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
rowsPerPageOptions={[10, 25, 100]}
|
||||
component='div'
|
||||
count={images.length}
|
||||
rowsPerPage={rowsPerPage}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
/>
|
||||
<Title mt='md'>Stats</Title>
|
||||
<Text>View more stats here <Link href='/dashboard/stats'>here</Link>.</Text>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
<Card name='Size' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.size : <Skeleton height={8} />}</StatText>
|
||||
<Title order={2}>Average Size</Title>
|
||||
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
<Card name='Images' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.count : <Skeleton height={8} />}</StatText>
|
||||
<Title order={2}>Views</Title>
|
||||
<StatText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
<Card name='Users' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.count_users : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<ImagesTable
|
||||
columns={[
|
||||
{ accessor: 'file', Header: 'Name', minWidth: 170, align: 'inherit' as Aligns },
|
||||
{ accessor: 'mimetype', Header: 'Type', minWidth: 100, align: 'inherit' as Aligns },
|
||||
{ accessor: 'created_at', Header: 'Date' },
|
||||
]}
|
||||
data={images}
|
||||
deleteImage={deleteImage}
|
||||
copyImage={copyImage}
|
||||
viewImage={viewImage}
|
||||
/>
|
||||
|
||||
{/* <Title mt='md'>Files</Title>
|
||||
<Text>View previews of your files in the <Link href='/dashboard/files'>browser</Link>.</Text>
|
||||
<ReactTable
|
||||
columns={[
|
||||
{ accessor: 'file', Header: 'Name', minWidth: 170, align: 'inherit' as Aligns },
|
||||
{ accessor: 'mimetype', Header: 'Type', minWidth: 100, align: 'inherit' as Aligns },
|
||||
{ accessor: 'created_at', Header: 'Date' },
|
||||
]}
|
||||
data={images}
|
||||
pagination
|
||||
/>
|
||||
<Card name='Files per User' mt={22}>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'username', name: 'Name' },
|
||||
{ id: 'count', name: 'Files' },
|
||||
]}
|
||||
rows={stats ? stats.count_by_user : []} />
|
||||
</Card>
|
||||
{stats && (
|
||||
<>
|
||||
<Card name='Images per User' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'username', name: 'Name' },
|
||||
{ id: 'count', name: 'Images' }
|
||||
]}
|
||||
rows={stats.count_by_user}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card name='Types' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'mimetype', name: 'Type' },
|
||||
{ id: 'count', name: 'Count' }
|
||||
]}
|
||||
rows={stats.types_count}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
<Card name='Types' mt={22}>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'mimetype', name: 'Type' },
|
||||
{ id: 'count', name: 'Count' },
|
||||
]}
|
||||
rows={stats ? stats.types_count : []} />
|
||||
</Card> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
104
src/components/pages/Files.tsx
Normal file
104
src/components/pages/Files.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import ZiplineImage from 'components/Image';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { Box, Accordion, Pagination, Title, SimpleGrid, Skeleton, Group, ActionIcon } from '@mantine/core';
|
||||
import { PlusIcon } from '@modulz/radix-icons';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Files() {
|
||||
const [pages, setPages] = useState([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [favoritePages, setFavoritePages] = useState([]);
|
||||
const [favoritePage, setFavoritePage] = useState(1);
|
||||
|
||||
const updatePages = async favorite => {
|
||||
const pages = await useFetch('/api/user/files?paged=true&filter=media');
|
||||
if (favorite) {
|
||||
const fPages = await useFetch('/api/user/files?paged=true&favorite=media');
|
||||
setFavoritePages(fPages);
|
||||
}
|
||||
setPages(pages);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updatePages(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group>
|
||||
<Title sx={{ marginBottom: 12 }}>Files</Title>
|
||||
<Link href='/dashboard/upload' passHref>
|
||||
<ActionIcon component='a' variant='filled' color='primary'><PlusIcon/></ActionIcon>
|
||||
</Link>
|
||||
</Group>
|
||||
<Accordion
|
||||
offsetIcon={false}
|
||||
sx={t => ({
|
||||
marginTop: 2,
|
||||
border: '1px solid',
|
||||
marginBottom: 12,
|
||||
borderColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0] ,
|
||||
})}
|
||||
>
|
||||
<Accordion.Item label={<Title>Favorite Files</Title>}>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{favoritePages.length ? favoritePages[(favoritePage - 1) ?? 0].map(image => (
|
||||
<div key={image.id}>
|
||||
<ZiplineImage image={image} updateImages={() => updatePages(true)} />
|
||||
</div>
|
||||
)) : null}
|
||||
</SimpleGrid>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Pagination total={favoritePages.length} page={favoritePage} onChange={setFavoritePage}/>
|
||||
</Box>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{pages.length ? pages[(page - 1) ?? 0].map(image => (
|
||||
<div key={image.id}>
|
||||
<ZiplineImage image={image} updateImages={() => updatePages(true)} />
|
||||
</div>
|
||||
)) : [1,2,3,4].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{pages.length ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Pagination total={pages.length} page={page} onChange={setPage}/>
|
||||
</Box>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Grid, Pagination, Box, Typography } from '@material-ui/core';
|
||||
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import ZiplineImage from 'components/Image';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
|
||||
export default function Upload() {
|
||||
const [pages, setPages] = useState([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const updatePages = async () => {
|
||||
setLoading(true);
|
||||
const pages = await useFetch('/api/user/images?paged=true&filter=image');
|
||||
setPages(pages);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updatePages();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={loading}/>
|
||||
{!pages.length && (
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
pt={2}
|
||||
pb={3}
|
||||
>
|
||||
<Typography variant='h4'>No Images</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Grid container spacing={2}>
|
||||
{pages.length ? pages[(page - 1) ?? 0].map(image => (
|
||||
<Grid item xs={12} sm={3} key={image.id}>
|
||||
<ZiplineImage image={image} updateImages={updatePages} />
|
||||
</Grid>
|
||||
)) : null}
|
||||
</Grid>
|
||||
|
||||
{pages.length ? (
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
pt={2}
|
||||
>
|
||||
<Pagination count={pages.length} page={page} onChange={(_, v) => setPage(v)}/>
|
||||
</Box>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,223 +1,119 @@
|
||||
import React, { useState } from 'react';
|
||||
import { TextField, Button, Box, Typography, Select, MenuItem } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
|
||||
import { useFormik } from 'formik';
|
||||
import * as yup from 'yup';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import Alert from 'components/Alert';
|
||||
import Link from 'components/Link';
|
||||
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
import { useRouter } from 'next/router';
|
||||
import { 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({
|
||||
username: yup
|
||||
.string()
|
||||
.required('Username is required')
|
||||
});
|
||||
|
||||
const themeValidationSchema = yup.object({
|
||||
type: yup
|
||||
.string()
|
||||
.required('Type (dark, light) is required is required'),
|
||||
primary: yup
|
||||
.string()
|
||||
.required('Primary color is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
secondary: yup
|
||||
.string()
|
||||
.required('Secondary color is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
error: yup
|
||||
.string()
|
||||
.required('Error color is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
warning: yup
|
||||
.string()
|
||||
.required('Warning color is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
info: yup
|
||||
.string()
|
||||
.required('Info color is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
border: yup
|
||||
.string()
|
||||
.required('Border color is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
mainBackground: yup
|
||||
.string()
|
||||
.required('Main Background is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
paperBackground: yup
|
||||
.string()
|
||||
.required('Paper Background is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
|
||||
});
|
||||
|
||||
function TextInput({ id, label, formik, ...other }) {
|
||||
function VarsTooltip({ children }) {
|
||||
return (
|
||||
<TextField
|
||||
id={id}
|
||||
name={id}
|
||||
label={label}
|
||||
value={formik.values[id]}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched[id] && Boolean(formik.errors[id])}
|
||||
helperText={formik.touched[id] && formik.errors[id]}
|
||||
variant='standard'
|
||||
fullWidth
|
||||
sx={{ pb: 0.5 }}
|
||||
{...other}
|
||||
/>
|
||||
<Tooltip position='top' placement='center' color='' label={
|
||||
<>
|
||||
<Text><b>{'{image.file}'}</b> - file name</Text>
|
||||
<Text><b>{'{image.mimetype}'}</b> - mimetype</Text>
|
||||
<Text><b>{'{image.id}'}</b> - id of the image</Text>
|
||||
<Text><b>{'{user.name}'}</b> - your username</Text>
|
||||
visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables
|
||||
</>
|
||||
}>
|
||||
{children}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Manage() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
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 config = {
|
||||
Version: '13.2.1',
|
||||
Name: 'Zipline',
|
||||
DestinationType: 'ImageUploader, TextUploader',
|
||||
RequestMethod: 'POST',
|
||||
RequestURL: `${window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : '')}/api/upload`,
|
||||
Headers: {
|
||||
Authorization: user?.token,
|
||||
...(withEmbed && {Embed: 'true'}),
|
||||
...(withZws && {ZWS: 'true'}),
|
||||
},
|
||||
URL: '$json:files[0]$',
|
||||
Body: 'MultipartFormData',
|
||||
FileFormName: 'file',
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
const pseudoElement = document.createElement('a');
|
||||
pseudoElement.setAttribute('href', 'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(config, null, '\t')));
|
||||
pseudoElement.setAttribute('download', `zipline${withEmbed ? '_embed' : ''}${withZws ? '_zws' : ''}.sxcu`);
|
||||
pseudoElement.style.display = 'none';
|
||||
document.body.appendChild(pseudoElement);
|
||||
pseudoElement.click();
|
||||
pseudoElement.parentNode.removeChild(pseudoElement);
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: user.username,
|
||||
password: '',
|
||||
embedTitle: user.embedTitle ?? '',
|
||||
embedColor: user.embedColor
|
||||
embedColor: user.embedColor,
|
||||
embedSiteName: user.embedSiteName ?? '',
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: async values => {
|
||||
const cleanUsername = values.username.trim();
|
||||
const cleanPassword = values.password.trim();
|
||||
const cleanEmbedTitle = values.embedTitle.trim();
|
||||
const cleanEmbedColor = values.embedColor.trim();
|
||||
|
||||
if (cleanUsername === '') return formik.setFieldError('username', 'Username can\'t be nothing');
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const data = {
|
||||
username: cleanUsername,
|
||||
password: cleanPassword === '' ? null : cleanPassword,
|
||||
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
|
||||
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor
|
||||
};
|
||||
|
||||
const newUser = await useFetch('/api/user', 'PATCH', data);
|
||||
|
||||
if (newUser.error) {
|
||||
setLoading(false);
|
||||
setMessage('An error occured');
|
||||
setSeverity('error');
|
||||
setOpen(true);
|
||||
} else {
|
||||
dispatch(updateUser(newUser));
|
||||
setLoading(false);
|
||||
setMessage('Saved user');
|
||||
setSeverity('success');
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const customThemeFormik = useFormik({
|
||||
initialValues: {
|
||||
type: user.customTheme?.type || 'dark',
|
||||
primary: user.customTheme?.primary || '',
|
||||
secondary: user.customTheme?.secondary || '',
|
||||
error: user.customTheme?.error || '',
|
||||
warning: user.customTheme?.warning || '',
|
||||
info: user.customTheme?.info || '',
|
||||
border: user.customTheme?.border || '',
|
||||
mainBackground: user.customTheme?.mainBackground || '',
|
||||
paperBackground: user.customTheme?.paperBackground || '',
|
||||
},
|
||||
validationSchema: themeValidationSchema,
|
||||
onSubmit: async values => {
|
||||
setLoading(true);
|
||||
const onSubmit = async values => {
|
||||
const cleanUsername = values.username.trim();
|
||||
const cleanPassword = values.password.trim();
|
||||
const cleanEmbedTitle = values.embedTitle.trim();
|
||||
const cleanEmbedColor = values.embedColor.trim();
|
||||
const cleanEmbedSiteName = values.embedSiteName.trim();
|
||||
|
||||
const newUser = await useFetch('/api/user', 'PATCH', { customTheme: values });
|
||||
console.log(newUser);
|
||||
if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
|
||||
|
||||
if (newUser.error) {
|
||||
setLoading(false);
|
||||
setMessage('An error occured');
|
||||
setSeverity('error');
|
||||
setOpen(true);
|
||||
} else {
|
||||
dispatch(updateUser(newUser));
|
||||
router.replace(router.pathname);
|
||||
setLoading(false);
|
||||
setMessage('Saved theme');
|
||||
setSeverity('success');
|
||||
setOpen(true);
|
||||
}
|
||||
const data = {
|
||||
username: cleanUsername,
|
||||
password: cleanPassword === '' ? null : cleanPassword,
|
||||
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
|
||||
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor,
|
||||
embedSiteName: cleanEmbedSiteName === '' ? null : cleanEmbedSiteName,
|
||||
};
|
||||
|
||||
const newUser = await useFetch('/api/user', 'PATCH', data);
|
||||
|
||||
if (newUser.error) {
|
||||
} else {
|
||||
dispatch(updateUser(newUser));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={loading}/>
|
||||
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
|
||||
|
||||
<Typography variant='h4' pb={2}>Manage User</Typography>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput id='username' label='Username' formik={formik} />
|
||||
<TextInput id='password' label='Password' formik={formik} type='password' />
|
||||
<TextInput id='embedTitle' label='Embed Title' formik={formik} />
|
||||
<TextInput id='embedColor' label='Embed Color' formik={formik} />
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='right'
|
||||
alignItems='right'
|
||||
pt={2}
|
||||
>
|
||||
<Title>Manage User</Title>
|
||||
<VarsTooltip>
|
||||
<Text color='gray'>Want to use variables in embed text? Hover on this or visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables</Text>
|
||||
</VarsTooltip>
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<TextInput id='username' label='Username' {...form.getInputProps('username')} />
|
||||
<TextInput id='password' label='Password'type='password' {...form.getInputProps('password')} />
|
||||
<TextInput id='embedTitle' label='Embed Title' {...form.getInputProps('embedTitle')} />
|
||||
<ColorInput id='embedColor' label='Embed Color' {...form.getInputProps('embedColor')} />
|
||||
<TextInput id='embedSiteName' label='Embed Site Name' {...form.getInputProps('embedSiteName')} />
|
||||
<Group position='right' sx={{ paddingTop: 12 }}>
|
||||
<Button
|
||||
variant='contained'
|
||||
type='submit'
|
||||
>Save User</Button>
|
||||
</Box>
|
||||
</form>
|
||||
<Typography variant='h4' py={2}>Manage Theme</Typography>
|
||||
<form onSubmit={customThemeFormik.handleSubmit}>
|
||||
<Select
|
||||
id='type'
|
||||
name='type'
|
||||
label='Type'
|
||||
value={customThemeFormik.values['type']}
|
||||
onChange={customThemeFormik.handleChange}
|
||||
error={customThemeFormik.touched['type'] && Boolean(customThemeFormik.errors['type'])}
|
||||
variant='standard'
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value='dark'>Dark Theme</MenuItem>
|
||||
<MenuItem value='light'>Light Theme</MenuItem>
|
||||
</Select>
|
||||
<TextInput id='primary' label='Primary Color' formik={customThemeFormik} />
|
||||
<TextInput id='secondary' label='Secondary Color' formik={customThemeFormik} />
|
||||
<TextInput id='error' label='Error Color' formik={customThemeFormik} />
|
||||
<TextInput id='warning' label='Warning Color' formik={customThemeFormik} />
|
||||
<TextInput id='info' label='Info Color' formik={customThemeFormik} />
|
||||
<TextInput id='border' label='Border Color' formik={customThemeFormik} />
|
||||
<TextInput id='mainBackground' label='Main Background' formik={customThemeFormik} />
|
||||
<TextInput id='paperBackground' label='Paper Background' formik={customThemeFormik} />
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='right'
|
||||
alignItems='right'
|
||||
pt={2}
|
||||
>
|
||||
<Button
|
||||
variant='contained'
|
||||
type='submit'
|
||||
>Save Theme</Button>
|
||||
</Box>
|
||||
</Group>
|
||||
</form>
|
||||
|
||||
<Title sx={{ paddingTop: 12, paddingBottom: 12 }}>ShareX Config</Title>
|
||||
<Group>
|
||||
<Button onClick={() => genShareX(false)} rightIcon={<DownloadIcon />}>ShareX Config</Button>
|
||||
<Button onClick={() => genShareX(true)} rightIcon={<DownloadIcon />}>ShareX Config with Embed</Button>
|
||||
<Button onClick={() => genShareX(false, true)} rightIcon={<DownloadIcon />}>ShareX Config with ZWS</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
106
src/components/pages/Stats.tsx
Normal file
106
src/components/pages/Stats.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import Card from 'components/Card';
|
||||
import StatText from 'components/StatText';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import { Box, Text, Table, Skeleton, Title, SimpleGrid } from '@mantine/core';
|
||||
import { randomId } from '@mantine/hooks';
|
||||
|
||||
export function bytesToRead(bytes: number) {
|
||||
if (isNaN(bytes)) return '0.0 B';
|
||||
if (bytes === Infinity) return '0.0 B';
|
||||
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let num = 0;
|
||||
|
||||
while (bytes > 1024) {
|
||||
bytes /= 1024;
|
||||
++num;
|
||||
}
|
||||
|
||||
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||
}
|
||||
|
||||
function StatTable({ rows, columns }) {
|
||||
return (
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<Table highlightOnHover>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map(col => (
|
||||
<th key={randomId()}>{col.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<tr key={randomId()}>
|
||||
{columns.map(col => (
|
||||
<td key={randomId()}>
|
||||
{col.format ? col.format(row[col.id]) : row[col.id]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Stats() {
|
||||
const [stats, setStats] = useState(null);
|
||||
|
||||
const update = async () => {
|
||||
const stts = await useFetch('/api/stats');
|
||||
setStats(stts);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
update();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Stats</Title>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
<Card name='Size' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.size : <Skeleton height={8} />}</StatText>
|
||||
<Title order={2}>Average Size</Title>
|
||||
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
<Card name='Images' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.count : <Skeleton height={8} />}</StatText>
|
||||
<Title order={2}>Views</Title>
|
||||
<StatText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
<Card name='Users' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.count_users : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Card name='Files per User' mt={22}>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'username', name: 'Name' },
|
||||
{ id: 'count', name: 'Files' },
|
||||
]}
|
||||
rows={stats ? stats.count_by_user : []} />
|
||||
</Card>
|
||||
<Card name='Types' mt={22}>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'mimetype', name: 'Type' },
|
||||
{ id: 'count', name: 'Count' },
|
||||
]}
|
||||
rows={stats ? stats.types_count : []} />
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +1,118 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Typography, Button, CardActionArea, Paper, Box } from '@material-ui/core';
|
||||
import { Upload as UploadIcon } from '@material-ui/icons';
|
||||
import Dropzone from 'react-dropzone';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import Alert from 'components/Alert';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import CenteredBox from 'components/CenteredBox';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import Link from 'components/Link';
|
||||
import { 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';
|
||||
|
||||
export default function Upload({ route }) {
|
||||
function ImageUploadIcon({ status, ...props }) {
|
||||
if (status.accepted) {
|
||||
return <UploadIcon {...props} />;
|
||||
}
|
||||
|
||||
if (status.rejected) {
|
||||
return <CrossCircledIcon {...props} />;
|
||||
}
|
||||
|
||||
return <ImageIcon {...props} />;
|
||||
}
|
||||
|
||||
function getIconColor(status, theme) {
|
||||
return status.accepted
|
||||
? theme.colors[theme.primaryColor][6]
|
||||
: status.rejected
|
||||
? theme.colors.red[6]
|
||||
: theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[0]
|
||||
: theme.black;
|
||||
}
|
||||
|
||||
export default function Upload() {
|
||||
const theme = useMantineTheme();
|
||||
const notif = useNotifications();
|
||||
const clipboard = useClipboard();
|
||||
const user = useStoreSelector(state => state.user);
|
||||
|
||||
const [file, setFile] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [severity, setSeverity] = useState('success');
|
||||
const [message, setMessage] = useState('Saved');
|
||||
const [files, setFiles] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('paste', (e: ClipboardEvent) => {
|
||||
const item = Array.from(e.clipboardData.items).find(x => /^image/.test(x.type));
|
||||
const blob = item.getAsFile();
|
||||
setFiles([...files, new File([blob], blob.name, { type: blob.type })]);
|
||||
notif.showNotification({
|
||||
title: 'Image Imported',
|
||||
message: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const handleUpload = async () => {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
for (let i = 0; i !== files.length; ++i) body.append('file', files[i]);
|
||||
|
||||
const id = notif.showNotification({
|
||||
title: 'Uploading Images...',
|
||||
message: '',
|
||||
loading: true,
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': user.token
|
||||
'Authorization': user.token,
|
||||
},
|
||||
body
|
||||
body,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (res.ok && json.error === undefined) {
|
||||
setOpen(true);
|
||||
setSeverity('success');
|
||||
setMessage(`Copied to clipboard! ${json.url}`);
|
||||
copy(json.url);
|
||||
notif.updateNotification(id, {
|
||||
title: 'Upload Successful',
|
||||
message: <>Copied first image to clipboard! <br/>{json.files.map(x => (<Link key={x} href={x}>{x}<br/></Link>))}</>,
|
||||
color: 'green',
|
||||
icon: <UploadIcon />,
|
||||
});
|
||||
clipboard.copy(json.url);
|
||||
setFiles([]);
|
||||
} else {
|
||||
setOpen(true);
|
||||
setSeverity('error');
|
||||
setMessage('Could not upload file: ' + json.error);
|
||||
notif.updateNotification(id, {
|
||||
title: 'Upload Failed',
|
||||
message: json.error,
|
||||
color: 'red',
|
||||
icon: <CrossCircledIcon />,
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={loading}/>
|
||||
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
|
||||
<Dropzone onDrop={(f) => setFiles([...files, ...f])}>
|
||||
{status => (
|
||||
<>
|
||||
<Group position='center' spacing='xl' style={{ minHeight: 220, pointerEvents: 'none' }}>
|
||||
<ImageUploadIcon
|
||||
status={status}
|
||||
style={{ width: 80, height: 80, color: getIconColor(status, theme) }}
|
||||
/>
|
||||
|
||||
<Typography variant='h4' pb={2}>Upload file</Typography>
|
||||
<Dropzone onDrop={acceptedFiles => setFile(acceptedFiles[0])}>
|
||||
{({getRootProps, getInputProps}) => (
|
||||
<CardActionArea>
|
||||
<Paper
|
||||
elevation={0}
|
||||
variant='outlined'
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
display: 'block',
|
||||
p: 5
|
||||
}}
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<CenteredBox><UploadIcon sx={{ fontSize: 100 }} /></CenteredBox>
|
||||
<CenteredBox><Typography variant='h5'>Drag an image or click to upload an image.</Typography></CenteredBox>
|
||||
<CenteredBox><Typography variant='h6'>{file && file.name}</Typography></CenteredBox>
|
||||
</Paper>
|
||||
</CardActionArea>
|
||||
<div>
|
||||
<Text size='xl' inline>
|
||||
Drag images here or click to select files
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group position='center' spacing='xl' style={{ pointerEvents: 'none' }}>
|
||||
{files.map(file => (<Text key={file.name} weight='bold'>{file.name}</Text>))}
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Dropzone>
|
||||
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='right'
|
||||
alignItems='right'
|
||||
pt={2}
|
||||
>
|
||||
<Button
|
||||
variant='contained'
|
||||
onClick={handleUpload}
|
||||
>Upload</Button>
|
||||
</Box>
|
||||
<Group position='right'>
|
||||
<Button leftIcon={<UploadIcon />} mt={12} onClick={handleUpload}>Upload</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
161
src/components/pages/Urls.tsx
Normal file
161
src/components/pages/Urls.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import { useClipboard, useForm } from '@mantine/hooks';
|
||||
import { CopyIcon, Cross1Icon, Link1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { Modal, Title, Group, Button, Box, Card, TextInput, ActionIcon, SimpleGrid, Skeleton } from '@mantine/core';
|
||||
|
||||
export default function Urls() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
const notif = useNotifications();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const [urls, setURLS] = useState([]);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const updateURLs = async () => {
|
||||
const urls = await useFetch('/api/user/urls');
|
||||
|
||||
setURLS(urls);
|
||||
};
|
||||
|
||||
const deleteURL = async u => {
|
||||
const url = await useFetch('/api/user/urls', 'DELETE', { id: u.id });
|
||||
if (url.error) {
|
||||
notif.showNotification({
|
||||
title: 'Failed to delete URL',
|
||||
message: url.error,
|
||||
icon: <TrashIcon />,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'Deleted URL',
|
||||
message: '',
|
||||
icon: <Cross1Icon />,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
|
||||
updateURLs();
|
||||
};
|
||||
|
||||
const copyURL = u => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
|
||||
notif.showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
url: '',
|
||||
vanity: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const cleanURL = values.url.trim();
|
||||
const cleanVanity = values.vanity.trim();
|
||||
|
||||
if (cleanURL === '') return form.setFieldError('url', 'URL can\'t be nothing');
|
||||
|
||||
const data = {
|
||||
url: cleanURL,
|
||||
vanity: cleanVanity === '' ? null : cleanVanity,
|
||||
};
|
||||
|
||||
setCreateOpen(false);
|
||||
const res = await fetch('/api/shorten', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': user.token,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
notif.showNotification({
|
||||
title: 'Failed to create URL',
|
||||
message: json.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'URL shortened',
|
||||
message: json.url,
|
||||
color: 'green',
|
||||
icon: <Link1Icon />,
|
||||
});
|
||||
}
|
||||
|
||||
updateURLs();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateURLs();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title={<Title>Shorten URL</Title>}
|
||||
>
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<TextInput id='url' label='URL' {...form.getInputProps('url')} />
|
||||
<TextInput id='vanity' label='Vanity' {...form.getInputProps('vanity')} />
|
||||
|
||||
<Group position='right' mt={22}>
|
||||
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button type='submit'>Submit</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Group>
|
||||
<Title sx={{ marginBottom: 12 }}>URLs</Title>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setCreateOpen(true)}><PlusIcon/></ActionIcon>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid
|
||||
cols={4}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{urls.length ? urls.map(url => (
|
||||
<Card key={url.id} sx={{ maxWidth: '100%' }} shadow='sm'>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Title>{url.vanity ?? url.id}</Title>
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
<ActionIcon href={url.url} component='a' target='_blank'><Link1Icon/></ActionIcon>
|
||||
<ActionIcon aria-label='copy' onClick={() => copyURL(url)}>
|
||||
<CopyIcon />
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='delete' onClick={() => deleteURL(url)}>
|
||||
<TrashIcon />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)) : [1,2,3,4,5,6,7].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={60} sx={{ borderRadius: 1 }}/>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,168 +1,115 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Card as MuiCard,
|
||||
CardHeader,
|
||||
Avatar,
|
||||
IconButton,
|
||||
Grid,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Switch,
|
||||
FormControlLabel
|
||||
} from '@material-ui/core';
|
||||
import { Delete as DeleteIcon, Add as AddIcon } from '@material-ui/icons';
|
||||
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import Alert from 'components/Alert';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useFormik } from 'formik';
|
||||
import { useForm } from '@mantine/hooks';
|
||||
import { Avatar, Modal, Title, TextInput, Group, Button, Card, ActionIcon, SimpleGrid, Switch, Skeleton } 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 }) {
|
||||
return (
|
||||
<TextField
|
||||
id={id}
|
||||
name={id}
|
||||
label={label}
|
||||
value={formik.values[id]}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched[id] && Boolean(formik.errors[id])}
|
||||
helperText={formik.touched[id] && formik.errors[id]}
|
||||
variant='standard'
|
||||
fullWidth
|
||||
sx={{ pb: 0.5 }}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateUserDialog({ open, setOpen, updateUsers, setSeverity, setMessage, setLoading, setAlertOpen }) {
|
||||
const formik = useFormik({
|
||||
function CreateUserModal({ open, setOpen, updateUsers }) {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
administrator: false
|
||||
administrator: false,
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
const cleanUsername = values.username.trim();
|
||||
const cleanPassword = values.password.trim();
|
||||
if (cleanUsername === '') return formik.setFieldError('username', 'Username can\'t be nothing');
|
||||
if (cleanPassword === '') return formik.setFieldError('password', 'Password can\'t be nothing');
|
||||
|
||||
const data = {
|
||||
username: cleanUsername,
|
||||
password: cleanPassword,
|
||||
administrator: values.administrator
|
||||
};
|
||||
|
||||
setOpen(false);
|
||||
setLoading(true);
|
||||
const res = await useFetch('/api/auth/create', 'POST', data);
|
||||
if (res.error) {
|
||||
setSeverity('error');
|
||||
setMessage('Could\'nt create user: ' + res.error);
|
||||
setAlertOpen(true);
|
||||
} else {
|
||||
setSeverity('success');
|
||||
setMessage('Created user ' + res.username);
|
||||
setAlertOpen(true);
|
||||
updateUsers();
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
const notif = useNotifications();
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const cleanUsername = values.username.trim();
|
||||
const cleanPassword = values.password.trim();
|
||||
if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
|
||||
if (cleanPassword === '') return form.setFieldError('password', 'Password can\'t be nothing');
|
||||
|
||||
const data = {
|
||||
username: cleanUsername,
|
||||
password: cleanPassword,
|
||||
administrator: values.administrator,
|
||||
};
|
||||
|
||||
setOpen(false);
|
||||
const res = await useFetch('/api/auth/create', 'POST', data);
|
||||
if (res.error) {
|
||||
notif.showNotification({
|
||||
title: 'Failed to create user',
|
||||
message: res.error,
|
||||
icon: <TrashIcon />,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'Created user: ' + cleanUsername,
|
||||
message: '',
|
||||
icon: <PlusIcon />,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
|
||||
updateUsers();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
PaperProps={{
|
||||
elevation: 1
|
||||
}}
|
||||
>
|
||||
<DialogTitle>
|
||||
Create User
|
||||
</DialogTitle>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<DialogContent>
|
||||
<TextInput id='username' label='Username' formik={formik} />
|
||||
<TextInput id='password' label='Password' formik={formik} type='password' />
|
||||
<FormControlLabel
|
||||
id='administrator'
|
||||
name='administrator'
|
||||
value={formik.values.administrator}
|
||||
onChange={formik.handleChange}
|
||||
control={<Switch />}
|
||||
label='Administrator?'
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
|
||||
<Button type='submit' color='inherit'>
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
</div>
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Title>Create User</Title>}
|
||||
>
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<TextInput id='username' label='Username' {...form.getInputProps('username')} />
|
||||
<TextInput id='password' label='Password' type='password' {...form.getInputProps('password')} />
|
||||
<Switch mt={12} id='administrator' label='Administrator' {...form.getInputProps('administrator')} />
|
||||
|
||||
<Group position='right' mt={22}>
|
||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button type='submit'>Create</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Users() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
const router = useRouter();
|
||||
const notif = useNotifications();
|
||||
|
||||
const [users, setUsers] = useState([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [severity, setSeverity] = useState('success');
|
||||
const [message, setMessage] = useState('Saved');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const updateUsers = async () => {
|
||||
setLoading(true);
|
||||
const us = await useFetch('/api/users');
|
||||
if (!us.error) {
|
||||
setUsers(us);
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
};
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (user) => {
|
||||
const res = await useFetch('/api/users', 'DELETE', {
|
||||
id: user.id
|
||||
id: user.id,
|
||||
});
|
||||
if (res.error) {
|
||||
setMessage(`Could not delete ${user.username}`);
|
||||
setSeverity('error');
|
||||
setOpen(true);
|
||||
notif.showNotification({
|
||||
title: 'Failed to delete user',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
} else {
|
||||
setMessage(`Deleted user ${res.username}`);
|
||||
setSeverity('success');
|
||||
setOpen(true);
|
||||
updateUsers();
|
||||
notif.showNotification({
|
||||
title: 'User deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <TrashIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
updateUsers();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -171,17 +118,38 @@ export default function Users() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={loading}/>
|
||||
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
|
||||
<CreateUserDialog open={createOpen} setOpen={setCreateOpen} setSeverity={setSeverity} setMessage={setMessage} setLoading={setLoading} updateUsers={updateUsers} setAlertOpen={setOpen} />
|
||||
<Typography variant='h4' pb={2}>Users <IconButton onClick={() => setCreateOpen(true)}><AddIcon /></IconButton></Typography>
|
||||
<Grid container spacing={2}>
|
||||
{users.filter(x => x.username !== user.username).map((user, i) => (
|
||||
<Grid item xs={12} sm={3} key={i}>
|
||||
<Card user={user} handleDelete={handleDelete}/>
|
||||
</Grid>
|
||||
<CreateUserModal open={open} setOpen={setOpen} updateUsers={updateUsers} />
|
||||
<Group>
|
||||
<Title sx={{ marginBottom: 12 }}>Users</Title>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon/></ActionIcon>
|
||||
</Group>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{users.length ? users.filter(x => x.username !== user.username).map((user, 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 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;
|
||||
36
src/lib/datasource/Local.ts
Normal file
36
src/lib/datasource/Local.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { readdir, readFile, stat, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { Datasource } from './datasource';
|
||||
|
||||
export class Local extends Datasource {
|
||||
public name: string = 'local';
|
||||
|
||||
public constructor(public path: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async save(file: string, data: Buffer): Promise<void> {
|
||||
await writeFile(join(process.cwd(), this.path, file), data);
|
||||
}
|
||||
|
||||
public async get(file: string): Promise<Buffer> {
|
||||
try {
|
||||
const data = await readFile(join(process.cwd(), this.path, file));
|
||||
return data;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async size(): Promise<number> {
|
||||
const files = await readdir(this.path);
|
||||
|
||||
let size = 0;
|
||||
for (let i = 0, L = files.length; i !== L; ++i) {
|
||||
const sta = await stat(join(this.path, files[i]));
|
||||
size += sta.size;
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
}
|
||||
66
src/lib/datasource/S3.ts
Normal file
66
src/lib/datasource/S3.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Datasource } from './datasource';
|
||||
import AWS from 'aws-sdk';
|
||||
|
||||
export class S3 extends Datasource {
|
||||
public name: string = 'S3';
|
||||
public s3: AWS.S3;
|
||||
|
||||
public constructor(
|
||||
public accessKey: string,
|
||||
public secretKey: string,
|
||||
public bucket: string,
|
||||
) {
|
||||
super();
|
||||
this.s3 = new AWS.S3({
|
||||
accessKeyId: accessKey,
|
||||
secretAccessKey: secretKey,
|
||||
});
|
||||
}
|
||||
|
||||
public async save(file: string, data: Buffer): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.s3.upload({
|
||||
Bucket: this.bucket,
|
||||
Key: file,
|
||||
Body: data,
|
||||
}, err => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async get(file: string): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.s3.getObject({
|
||||
Bucket: this.bucket,
|
||||
Key: file,
|
||||
}, (err, data) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
resolve(Buffer.from(data.Body));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async size(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.s3.listObjects({
|
||||
Bucket: this.bucket,
|
||||
}, (err, data) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
const size = data.Contents.reduce((acc, cur) => acc + cur.Size, 0);
|
||||
resolve(size);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
7
src/lib/datasource/datasource.ts
Normal file
7
src/lib/datasource/datasource.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export abstract class Datasource {
|
||||
public name: string;
|
||||
|
||||
public abstract save(file: string, data: Buffer): Promise<void>;
|
||||
public abstract get(file: string): Promise<Buffer>;
|
||||
public abstract size(): Promise<number>;
|
||||
}
|
||||
4
src/lib/datasource/index.ts
Normal file
4
src/lib/datasource/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { Datasource } from './datasource';
|
||||
export { Local } from './Local';
|
||||
export { S3 } from './S3';
|
||||
|
||||
20
src/lib/ds.ts
Normal file
20
src/lib/ds.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import config from './config';
|
||||
import { S3, Local } from './datasource';
|
||||
import Logger from './logger';
|
||||
|
||||
if (!global.datasource) {
|
||||
switch (config.datasource.type) {
|
||||
case 's3':
|
||||
global.datasource = new S3(config.datasource.s3.access_key_id, config.datasource.s3.secret_access_key, config.datasource.s3.bucket);
|
||||
Logger.get('datasource').info(`Using S3:${config.datasource.s3.bucket} datasource`);
|
||||
break;
|
||||
case 'local':
|
||||
global.datasource = new Local(config.datasource.local.directory);
|
||||
Logger.get('datasource').info(`Using local:${config.datasource.local.directory} datasource`);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid datasource type');
|
||||
}
|
||||
};
|
||||
|
||||
export default global.datasource;
|
||||
@@ -5,7 +5,7 @@ export default async function useFetch(url: string, method: 'GET' | 'POST' | 'PA
|
||||
const res = await global.fetch(url, {
|
||||
body: body ? JSON.stringify(body) : null,
|
||||
method,
|
||||
headers
|
||||
headers,
|
||||
});
|
||||
|
||||
return res.json();
|
||||
|
||||
@@ -14,21 +14,17 @@ export default function login() {
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
|
||||
const res = await useFetch('/api/user');
|
||||
|
||||
if (res.error) return router.push('/auth/login');
|
||||
if (res.error) return router.push('/auth/login?url=' + router.route);
|
||||
|
||||
dispatch(updateUser(res));
|
||||
|
||||
setUser(res);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && user) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!loading && user) return;
|
||||
load();
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
const { format } = require('fecha');
|
||||
const { yellow, blueBright, magenta, red, cyan } = require('colorette');
|
||||
|
||||
class Logger {
|
||||
static get(clas) {
|
||||
if (typeof clas !== 'function') if (typeof clas !== 'string') throw new Error('not string/function');
|
||||
|
||||
const name = clas.name ?? clas;
|
||||
|
||||
return new Logger(name);
|
||||
}
|
||||
|
||||
constructor (name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
info(message) {
|
||||
console.log(this.formatMessage('INFO', this.name, message));
|
||||
}
|
||||
|
||||
error(error) {
|
||||
console.log(this.formatMessage('ERROR', this.name, error.toString()));
|
||||
}
|
||||
|
||||
formatMessage(level, name, message) {
|
||||
const time = format(new Date(), 'YYYY-MM-DD hh:mm:ss,SSS A');
|
||||
return `${time} ${this.formatLevel(level)} [${blueBright(name)}] ${message}`;
|
||||
}
|
||||
|
||||
formatLevel(level) {
|
||||
switch (level) {
|
||||
case 'INFO':
|
||||
return cyan('INFO ');
|
||||
case 'DEBUG':
|
||||
return yellow('DEBUG');
|
||||
case 'WARN':
|
||||
return magenta('WARN ');
|
||||
case 'ERROR':
|
||||
return red('ERROR');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Logger;
|
||||
45
src/lib/logger.ts
Normal file
45
src/lib/logger.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { format } from 'fecha';
|
||||
import { blueBright, red, cyan } from 'colorette';
|
||||
|
||||
export enum LoggerLevel {
|
||||
ERROR,
|
||||
INFO,
|
||||
}
|
||||
|
||||
export default class Logger {
|
||||
public name: string;
|
||||
|
||||
static get(clas: any) {
|
||||
if (typeof clas !== 'function') if (typeof clas !== 'string') throw new Error('not string/function');
|
||||
|
||||
const name = clas.name ?? clas;
|
||||
|
||||
return new Logger(name);
|
||||
}
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
info(message: string) {
|
||||
console.log(this.formatMessage(LoggerLevel.INFO, this.name, message));
|
||||
}
|
||||
|
||||
error(error: any) {
|
||||
console.log(this.formatMessage(LoggerLevel.ERROR, this.name, error.stack ?? error));
|
||||
}
|
||||
|
||||
formatMessage(level: LoggerLevel, name, message) {
|
||||
const time = format(new Date(), 'YYYY-MM-DD hh:mm:ss,SSS A');
|
||||
return `${time} ${this.formatLevel(level)} [${blueBright(name)}] ${message}`;
|
||||
}
|
||||
|
||||
formatLevel(level: LoggerLevel) {
|
||||
switch (level) {
|
||||
case LoggerLevel.INFO:
|
||||
return cyan('INFO ');
|
||||
case LoggerLevel.ERROR:
|
||||
return red('ERROR');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { CookieSerializeOptions } from 'cookie';
|
||||
import type { Image, Theme, User } from '@prisma/client';
|
||||
|
||||
import { serialize } from 'cookie';
|
||||
import { sign64, unsign64 } from '../util';
|
||||
@@ -12,7 +11,7 @@ export interface NextApiFile {
|
||||
originalname: string;
|
||||
encoding: string;
|
||||
mimetype: string;
|
||||
buffer: string;
|
||||
buffer: Buffer;
|
||||
size: number;
|
||||
}
|
||||
|
||||
@@ -23,15 +22,13 @@ export type NextApiReq = NextApiRequest & {
|
||||
embedTitle: string;
|
||||
embedColor: string;
|
||||
systemTheme: string;
|
||||
customTheme?: Theme;
|
||||
administrator: boolean;
|
||||
id: number;
|
||||
images: Image[];
|
||||
password: string;
|
||||
} | null | void>;
|
||||
getCookie: (name: string) => string | null;
|
||||
cleanCookie: (name: string) => void;
|
||||
file?: NextApiFile;
|
||||
files?: NextApiFile[];
|
||||
}
|
||||
|
||||
export type NextApiRes = NextApiResponse & {
|
||||
@@ -39,14 +36,19 @@ export type NextApiRes = NextApiResponse & {
|
||||
forbid: (message: string) => void;
|
||||
bad: (message: string) => void;
|
||||
json: (json: any) => void;
|
||||
ratelimited: () => void;
|
||||
setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void;
|
||||
}
|
||||
|
||||
export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse) => unknown) => (req: NextApiReq, res: NextApiRes) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Content-Allow-Methods', 'GET,HEAD,POST,OPTIONS');
|
||||
res.setHeader('Access-Control-Max-Age', '86400');
|
||||
|
||||
res.error = (message: string) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.json({
|
||||
error: message
|
||||
error: message,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -54,21 +56,26 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.status(403);
|
||||
res.json({
|
||||
error: '403: ' + message
|
||||
error: '403: ' + message,
|
||||
});
|
||||
};
|
||||
|
||||
res.bad = (message: string) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.status(401);
|
||||
res.json({
|
||||
error: '403: ' + message
|
||||
error: '403: ' + message,
|
||||
});
|
||||
};
|
||||
|
||||
res.ratelimited = () => {
|
||||
res.status(429);
|
||||
|
||||
res.json({
|
||||
error: '429: ratelimited',
|
||||
});
|
||||
};
|
||||
|
||||
res.json = (json: any) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
res.end(JSON.stringify(json));
|
||||
};
|
||||
|
||||
@@ -83,7 +90,7 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
||||
res.setHeader('Set-Cookie', serialize(name, '', {
|
||||
path: '/',
|
||||
expires: new Date(1),
|
||||
maxAge: undefined
|
||||
maxAge: undefined,
|
||||
}));
|
||||
};
|
||||
req.user = async () => {
|
||||
@@ -93,20 +100,18 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: Number(userId)
|
||||
id: Number(userId),
|
||||
},
|
||||
select: {
|
||||
administrator: true,
|
||||
embedColor: true,
|
||||
embedTitle: true,
|
||||
id: true,
|
||||
images: true,
|
||||
password: true,
|
||||
systemTheme: true,
|
||||
customTheme: true,
|
||||
token: true,
|
||||
username: true
|
||||
}
|
||||
username: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
@@ -132,11 +137,11 @@ export const setCookie = (
|
||||
) => {
|
||||
|
||||
if ('maxAge' in options) {
|
||||
options.expires = new Date(Date.now() + options.maxAge);
|
||||
options.expires = new Date(Date.now() + options.maxAge * 1000);
|
||||
options.maxAge /= 1000;
|
||||
}
|
||||
|
||||
const signed = sign64(String(value), config.core.secret);
|
||||
|
||||
res.setHeader('Set-Cookie', serialize(name, signed, options));
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
if (!global.prisma) global.prisma = new PrismaClient();
|
||||
if (!global.prisma) {
|
||||
if (!process.env.ZIPLINE_DOCKER_BUILD) global.prisma = new PrismaClient();
|
||||
};
|
||||
|
||||
export default global.prisma;
|
||||
@@ -1,83 +0,0 @@
|
||||
const { existsSync, readFileSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
const Logger = require('./logger');
|
||||
|
||||
const e = (val, type, fn) => ({ val, type, fn });
|
||||
|
||||
const envValues = [
|
||||
e('SECURE', 'boolean', (c, v) => c.core.secure = v),
|
||||
e('SECRET', 'string', (c, v) => c.core.secret = v),
|
||||
e('HOST', 'string', (c, v) => c.core.host = v),
|
||||
e('PORT', 'number', (c, v) => c.core.port = v),
|
||||
e('DATABASE_TYPE', 'string', (c, v) => c.database.type = v),
|
||||
e('DATABASE_URL', 'string', (c, v) => c.database.url = v),
|
||||
e('UPLOADER_ROUTE', 'string', (c, v) => c.uploader.route = v),
|
||||
e('UPLOADER_EMBED_ROUTE', 'string', (c, v) => c.uploader.embed_route = v),
|
||||
e('UPLOADER_LENGTH', 'number', (c, v) => c.uploader.length = v),
|
||||
e('UPLOADER_DIRECTORY', 'string', (c, v) => c.uploader.directory = v)
|
||||
];
|
||||
|
||||
module.exports = () => {
|
||||
if (!existsSync(join(process.cwd(), 'config.toml'))) {
|
||||
Logger.get('config').info('reading environment');
|
||||
return tryReadEnv();
|
||||
} else {
|
||||
Logger.get('config').info('reading config file');
|
||||
const str = readFileSync(join(process.cwd(), 'config.toml'), 'utf8');
|
||||
const parsed = require('@iarna/toml/parse-string')(str);
|
||||
|
||||
return parsed;
|
||||
}
|
||||
};
|
||||
|
||||
function tryReadEnv() {
|
||||
const config = {
|
||||
core: {
|
||||
secure: undefined,
|
||||
secret: undefined,
|
||||
host: undefined,
|
||||
port: undefined
|
||||
},
|
||||
database: {
|
||||
type: undefined,
|
||||
url: undefined
|
||||
},
|
||||
uploader: {
|
||||
route: undefined,
|
||||
embed_route: undefined,
|
||||
length: undefined,
|
||||
directory: undefined
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0, L = envValues.length; i !== L; ++i) {
|
||||
const envValue = envValues[i];
|
||||
let value = process.env[envValue.val];
|
||||
|
||||
if (!value) {
|
||||
Logger.get('config').error('there is no config file or required environment variables... exiting...');
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
envValues[i].fn(config, value);
|
||||
if (envValue.type === 'number') value = parseToNumber(value);
|
||||
else if (envValue.type === 'boolean') value = parseToBoolean(value);
|
||||
envValues[i].fn(config, value);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function parseToNumber(value) {
|
||||
// infer that it is a string since env values are only strings
|
||||
const number = Number(value);
|
||||
if (isNaN(number)) return undefined;
|
||||
return number;
|
||||
}
|
||||
|
||||
function parseToBoolean(value) {
|
||||
// infer that it is a string since env values are only strings
|
||||
if (!value || value === 'false') return false;
|
||||
else return true;
|
||||
}
|
||||
123
src/lib/readConfig.ts
Normal file
123
src/lib/readConfig.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import parse from '@iarna/toml/parse-string';
|
||||
import Logger from './logger';
|
||||
import { Config } from './types';
|
||||
|
||||
const e = (val, type, fn) => ({ val, type, fn });
|
||||
|
||||
const envValues = [
|
||||
e('SECURE', 'boolean', (c, v) => c.core.secure = v),
|
||||
e('SECRET', 'string', (c, v) => c.core.secret = v),
|
||||
e('HOST', 'string', (c, v) => c.core.host = v),
|
||||
e('PORT', 'number', (c, v) => c.core.port = v),
|
||||
e('DATABASE_URL', 'string', (c, v) => c.core.database_url = v),
|
||||
e('LOGGER', 'boolean', (c, v) => c.core.logger = v ?? true),
|
||||
e('STATS_INTERVAL', 'number', (c, v) => c.core.stats_interval = v),
|
||||
|
||||
e('DATASOURCE_TYPE', 'string', (c, v) => c.datasource.type = v),
|
||||
e('DATASOURCE_LOCAL_DIRECTORY', 'string', (c, v) => c.datasource.local.directory = v),
|
||||
e('DATASOURCE_S3_ACCESS_KEY_ID', 'string', (c, v) => c.datasource.s3.access_key_id = v ),
|
||||
e('DATASOURCE_S3_SECRET_ACCESS_KEY', 'string', (c, v) => c.datasource.s3.secret_access_key = v),
|
||||
e('DATASOURCE_S3_BUCKET', 'string', (c, v) => c.datasource.s3.bucket = v),
|
||||
|
||||
e('UPLOADER_ROUTE', 'string', (c, v) => c.uploader.route = v),
|
||||
e('UPLOADER_LENGTH', 'number', (c, v) => c.uploader.length = v),
|
||||
e('UPLOADER_ADMIN_LIMIT', 'number', (c, v) => c.uploader.admin_limit = v),
|
||||
e('UPLOADER_USER_LIMIT', 'number', (c, v) => c.uploader.user_limit = v),
|
||||
e('UPLOADER_DISABLED_EXTS', 'array', (c, v) => v ? c.uploader.disabled_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),
|
||||
];
|
||||
|
||||
export default function readConfig(): Config {
|
||||
if (!existsSync(join(process.cwd(), 'config.toml'))) {
|
||||
if (!process.env.ZIPLINE_DOCKER_BUILD) Logger.get('config').info('reading environment');
|
||||
return tryReadEnv();
|
||||
} else {
|
||||
if (process.env.ZIPLINE_DOCKER_BUILD) return;
|
||||
|
||||
Logger.get('config').info('reading config file');
|
||||
const str = readFileSync(join(process.cwd(), 'config.toml'), 'utf8');
|
||||
const parsed = parse(str);
|
||||
|
||||
return parsed;
|
||||
}
|
||||
};
|
||||
|
||||
function tryReadEnv(): Config {
|
||||
const config = {
|
||||
core: {
|
||||
secure: undefined,
|
||||
secret: undefined,
|
||||
host: undefined,
|
||||
port: undefined,
|
||||
database_url: undefined,
|
||||
logger: undefined,
|
||||
stats_interval: undefined,
|
||||
},
|
||||
datasource: {
|
||||
type: undefined,
|
||||
local: {
|
||||
directory: undefined,
|
||||
},
|
||||
s3: {
|
||||
access_key_id: undefined,
|
||||
secret_access_key: undefined,
|
||||
bucket: undefined,
|
||||
},
|
||||
},
|
||||
uploader: {
|
||||
route: undefined,
|
||||
length: undefined,
|
||||
admin_limit: undefined,
|
||||
user_limit: undefined,
|
||||
disabled_extentions: undefined,
|
||||
},
|
||||
urls: {
|
||||
route: undefined,
|
||||
length: undefined,
|
||||
},
|
||||
ratelimit: {
|
||||
user: undefined,
|
||||
admin: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
for (let i = 0, L = envValues.length; i !== L; ++i) {
|
||||
const envValue = envValues[i];
|
||||
let value: any = process.env[envValue.val];
|
||||
|
||||
if (!value) {
|
||||
envValues[i].fn(config, undefined);
|
||||
} else {
|
||||
envValues[i].fn(config, value);
|
||||
if (envValue.type === 'number') value = parseToNumber(value);
|
||||
else if (envValue.type === 'boolean') value = parseToBoolean(value);
|
||||
else if (envValue.type === 'array') value = parseToArray(value);
|
||||
envValues[i].fn(config, value);
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function parseToNumber(value) {
|
||||
// infer that it is a string since env values are only strings
|
||||
const number = Number(value);
|
||||
if (isNaN(number)) return undefined;
|
||||
return number;
|
||||
}
|
||||
|
||||
function parseToBoolean(value) {
|
||||
// infer that it is a string since env values are only strings
|
||||
if (!value || value === 'false') return false;
|
||||
else return true;
|
||||
}
|
||||
|
||||
function parseToArray(value) {
|
||||
return value.split(',');
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Theme } from '@prisma/client';
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
export interface User {
|
||||
@@ -6,8 +5,8 @@ export interface User {
|
||||
token: string;
|
||||
embedTitle: string;
|
||||
embedColor: string;
|
||||
embedSiteName: string;
|
||||
systemTheme: string;
|
||||
customTheme?: Theme;
|
||||
}
|
||||
|
||||
const initialState: User = null;
|
||||
|
||||
@@ -3,15 +3,36 @@
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'dark',
|
||||
primary: '#E6B450',
|
||||
secondary: '#FFEE99',
|
||||
error: '#F07178',
|
||||
warning: '#F29668',
|
||||
info: '#95E6CB',
|
||||
border: '#0D1016',
|
||||
background: {
|
||||
main: '#0A0E14',
|
||||
paper: '#0D1016'
|
||||
}
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'orange',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#0a0e14',
|
||||
hover: '#191e29',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#ffffff',
|
||||
'#47494E',
|
||||
'#6c707a',
|
||||
'#33353B',
|
||||
'#303238',
|
||||
'#2C2E34',
|
||||
'#25272D',
|
||||
'#0d1016',
|
||||
'#11141A',
|
||||
'#0D1016',
|
||||
],
|
||||
orange: [
|
||||
'#FFFFFF',
|
||||
'#FCF6EA',
|
||||
'#F9EDD4',
|
||||
'#F3DAA8',
|
||||
'#F2D69D',
|
||||
'#F0D192',
|
||||
'#EFCC87',
|
||||
'#EDC77C',
|
||||
'#EABE66',
|
||||
'#E6B450',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -3,15 +3,24 @@
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'light',
|
||||
primary: '#FF9940',
|
||||
secondary: '#E6BA7E',
|
||||
error: '#F07171',
|
||||
warning: '#ED9366',
|
||||
info: '#95E6CB',
|
||||
border: '#FFFFFF',
|
||||
background: {
|
||||
main: '#FAFAFA',
|
||||
paper: '#FFFFFF'
|
||||
}
|
||||
colorScheme: 'light',
|
||||
primaryColor: 'orange',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#FAFAFA',
|
||||
hover: '#FAFAFA',
|
||||
},
|
||||
colors: {
|
||||
orange: [
|
||||
'#FFFFFF',
|
||||
'#FCF6EA',
|
||||
'#F9EDD4',
|
||||
'#F3DAA8',
|
||||
'#F2D69D',
|
||||
'#F0D192',
|
||||
'#EFCC87',
|
||||
'#EDC77C',
|
||||
'#EABE66',
|
||||
'#E6B450',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -3,15 +3,36 @@
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'dark',
|
||||
primary: '#FFCC66',
|
||||
secondary: '#FFD580',
|
||||
error: '#F28779',
|
||||
warning: '#F29E74',
|
||||
info: '#95E6CB',
|
||||
border: '#232834',
|
||||
background: {
|
||||
main: '#1F2430',
|
||||
paper: '#232834'
|
||||
}
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'orange',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#1F2430',
|
||||
hover: '#2a2f3b',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#ffffff',
|
||||
'#91949A',
|
||||
'#6c707a',
|
||||
'#3F434E',
|
||||
'#313641',
|
||||
'#2A2F3B',
|
||||
'#2e333e',
|
||||
'#232834',
|
||||
'#11141A',
|
||||
'#0D1016',
|
||||
],
|
||||
orange: [
|
||||
'#FFFFFF',
|
||||
'#FCF6EA',
|
||||
'#F9EDD4',
|
||||
'#F3DAA8',
|
||||
'#F2D69D',
|
||||
'#F0D192',
|
||||
'#EFCC87',
|
||||
'#EDC77C',
|
||||
'#EABE66',
|
||||
'#E6B450',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -1,15 +1,36 @@
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'dark',
|
||||
primary: '#2c39a6',
|
||||
secondary: '#7344e2',
|
||||
error: '#ff4141',
|
||||
warning: '#ff9800',
|
||||
info: '#2f6fb9',
|
||||
border: '#2b2b2b',
|
||||
background: {
|
||||
main: '#000000',
|
||||
paper: '#060606'
|
||||
}
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'blue',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#000000',
|
||||
hover: '#2b2b2b',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#ffffff',
|
||||
'#A7A9AD',
|
||||
'#7B7E84',
|
||||
'#61646A',
|
||||
'#54575D',
|
||||
'#46494F',
|
||||
'#3C3F44',
|
||||
'#060606',
|
||||
'#141517',
|
||||
'#000000',
|
||||
],
|
||||
blue: [
|
||||
'#FFFFFF',
|
||||
'#7C7DC2',
|
||||
'#7778C0',
|
||||
'#6C6FBC',
|
||||
'#575DB5',
|
||||
'#4D54B2',
|
||||
'#424BAE',
|
||||
'#3742AA',
|
||||
'#323EA8',
|
||||
'#2C39A6',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -1,15 +1,36 @@
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'dark',
|
||||
primary: '#2c39a6',
|
||||
secondary: '#7344e2',
|
||||
error: '#ff4141',
|
||||
warning: '#ff9800',
|
||||
info: '#2f6fb9',
|
||||
border: '#1b2541',
|
||||
background: {
|
||||
main: '#05070f',
|
||||
paper: '#0c101c'
|
||||
}
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'blue',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#05070f',
|
||||
hover: '#181c28',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#FFFFFF',
|
||||
'#293747',
|
||||
'#6C7A8D',
|
||||
'#232F41',
|
||||
'#41566e',
|
||||
'#171F35',
|
||||
'#181c28',
|
||||
'#0c101c',
|
||||
'#060824',
|
||||
'#00001E',
|
||||
],
|
||||
blue: [
|
||||
'#FFFFFF',
|
||||
'#7C7DC2',
|
||||
'#7778C0',
|
||||
'#6C6FBC',
|
||||
'#575DB5',
|
||||
'#4D54B2',
|
||||
'#424BAE',
|
||||
'#3742AA',
|
||||
'#323EA8',
|
||||
'#2C39A6',
|
||||
],
|
||||
},
|
||||
});
|
||||
38
src/lib/themes/dracula.ts
Normal file
38
src/lib/themes/dracula.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// https://github.com/AlphaNecron/
|
||||
// https://github.com/dracula/dracula-theme
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'violet',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#282A36',
|
||||
hover: '#4e5062',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#FFFFFF',
|
||||
'#CED0D4',
|
||||
'#E8E8EB',
|
||||
'#D1D1D6',
|
||||
'#BABAC2',
|
||||
'#A2A3AD',
|
||||
'#4e5062',
|
||||
'#44475A',
|
||||
'#5C5E6F',
|
||||
'#44475A',
|
||||
],
|
||||
violet: [
|
||||
'#FFFFFF',
|
||||
'#F7F2FF',
|
||||
'#EFE4FE',
|
||||
'#EBDEFE',
|
||||
'#E7D7FD',
|
||||
'#DEC9FC',
|
||||
'#D6BCFC',
|
||||
'#CEAEFB',
|
||||
'#C6A1FA',
|
||||
'#BD93F9',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -1,54 +1,5 @@
|
||||
import { createTheme as muiCreateTheme } from '@material-ui/core/styles';
|
||||
import { MantineThemeOverride } from '@mantine/core';
|
||||
|
||||
export interface ThemeOptions {
|
||||
type: 'dark' | 'light';
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
export default function createTheme(o: MantineThemeOverride) {
|
||||
return o;
|
||||
}
|
||||
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 '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'dark',
|
||||
primary: '#81A1C1',
|
||||
secondary: '#88C0D0',
|
||||
error: '#BF616A',
|
||||
warning: '#EBCB8B',
|
||||
info: '#5E81AC',
|
||||
border: '#3B4252',
|
||||
background: {
|
||||
main: '#2E3440',
|
||||
paper: '#3B4252'
|
||||
}
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'blue',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#2E3440',
|
||||
hover: '#6c727e',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#FFFFFF',
|
||||
'#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: '#E5E9F0',
|
||||
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',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -10,29 +10,75 @@ export interface ConfigCore {
|
||||
|
||||
// The port Zipline will run on
|
||||
port: number;
|
||||
|
||||
// The PostgreSQL database url
|
||||
database_url: string
|
||||
|
||||
// Whether or not to log stuff
|
||||
logger: boolean;
|
||||
|
||||
// The interval to store stats
|
||||
stats_interval: number;
|
||||
}
|
||||
|
||||
export interface ConfigDatabase {
|
||||
type: 'psql';
|
||||
url: string;
|
||||
export interface ConfigDatasource {
|
||||
// The type of datasource
|
||||
type: 'local' | 's3';
|
||||
|
||||
// The local datasource
|
||||
local: ConfigLocalDatasource;
|
||||
s3?: ConfigS3Datasource;
|
||||
}
|
||||
|
||||
export interface ConfigLocalDatasource {
|
||||
// The directory to store files in
|
||||
directory: string;
|
||||
}
|
||||
|
||||
export interface ConfigS3Datasource {
|
||||
access_key_id: string;
|
||||
secret_access_key: string;
|
||||
bucket: string;
|
||||
}
|
||||
|
||||
export interface ConfigUploader {
|
||||
// The route uploads will be served on
|
||||
route: string;
|
||||
|
||||
// The route embedded routes will be served on
|
||||
embed_route: string;
|
||||
|
||||
// Length of random chars to generate for file names
|
||||
length: number;
|
||||
|
||||
// Where uploads are stored
|
||||
directory: string;
|
||||
// Admin file upload limit
|
||||
admin_limit: number;
|
||||
|
||||
// User file upload limit
|
||||
user_limit: number;
|
||||
|
||||
// Disabled extensions to block from uploading
|
||||
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 {
|
||||
core: ConfigCore;
|
||||
database: ConfigDatabase;
|
||||
uploader: ConfigUploader;
|
||||
}
|
||||
urls: ConfigUrls;
|
||||
ratelimit: ConfigRatelimit;
|
||||
datasource: ConfigDatasource;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
|
||||
import { hash, verify } from 'argon2';
|
||||
import { readdir, stat } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import prisma from './prisma';
|
||||
import { InvisibleImage, InvisibleUrl } from '@prisma/client';
|
||||
import config from './config';
|
||||
|
||||
export async function hashPassword(s: string): Promise<string> {
|
||||
return await hash(s);
|
||||
@@ -88,20 +90,21 @@ export function bytesToRead(bytes: number) {
|
||||
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
|
||||
const invisibleCharset = ['\u200B', '\u2060', '\u200C', '\u200D'];
|
||||
for (var i = 0, output = ''; i <= length; ++i) output += invisibleCharset[Math.floor(Math.random() * 4)];
|
||||
return output;
|
||||
|
||||
return [...randomBytes(length)].map((byte) => invisibleCharset[Number(byte) % invisibleCharset.length]).join('').slice(1).concat(invisibleCharset[0]);
|
||||
}
|
||||
|
||||
export function createInvis(length: number, imageId: number) {
|
||||
const retry = async () => {
|
||||
const invis = createInvisURL(length);
|
||||
export function createInvisImage(length: number, imageId: number) {
|
||||
const retry = async (): Promise<InvisibleImage> => {
|
||||
const invis = randomInvis(length);
|
||||
|
||||
const existing = await prisma.invisibleImage.findUnique({
|
||||
where: {
|
||||
invis
|
||||
}
|
||||
invis,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) return retry();
|
||||
@@ -109,12 +112,37 @@ export function createInvis(length: number, imageId: number) {
|
||||
const inv = await prisma.invisibleImage.create({
|
||||
data: {
|
||||
invis,
|
||||
id: imageId
|
||||
}
|
||||
imageId,
|
||||
},
|
||||
});
|
||||
|
||||
return inv;
|
||||
};
|
||||
|
||||
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 { Box, Typography } from '@material-ui/core';
|
||||
import { Box, Text } from '@mantine/core';
|
||||
|
||||
export default function FourOhFour() {
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
minHeight='100vh'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant='h2'>404 - Not Found</Typography>
|
||||
<Text size='xl'>404 - Not Found</Text>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,38 +1,53 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import Head from 'next/head';
|
||||
import { GetServerSideProps } from 'next';
|
||||
import { Box } from '@material-ui/core';
|
||||
import { Box } from '@mantine/core';
|
||||
import config from 'lib/config';
|
||||
import prisma from 'lib/prisma';
|
||||
import { parse } from 'lib/clientUtils';
|
||||
import * as exts from '../../scripts/exts';
|
||||
|
||||
export default function EmbeddedImage({ image, title, username, color, normal, embed }) {
|
||||
export default function EmbeddedImage({ image, user }) {
|
||||
const dataURL = (route: string) => `${route}/${image.file}`;
|
||||
|
||||
// reapply date from workaround
|
||||
image.created_at = new Date(image.created_at);
|
||||
|
||||
const updateImage = () => {
|
||||
const imageEl = document.getElementById('image_content') as HTMLImageElement;
|
||||
|
||||
const original = new Image;
|
||||
original.src = dataURL('/r');
|
||||
|
||||
if (original.width > innerWidth) imageEl.width = Math.floor(original.width * Math.min((innerHeight / original.height), (innerWidth / original.width)));
|
||||
else imageEl.width = original.width;
|
||||
};
|
||||
|
||||
useEffect(() => updateImage(), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
{title ? (
|
||||
{image.embed && (
|
||||
<>
|
||||
<meta property='og:site_name' content={`${image.file} • ${username}`} />
|
||||
<meta property='og:title' content={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='theme-color' content={user.embedColor}/>
|
||||
</>
|
||||
) : (
|
||||
<meta property='og:title' content={`${image.file} • ${username}`} />
|
||||
)}
|
||||
<meta property='theme-color' content={color}/>
|
||||
<meta property='og:url' content={dataURL(embed)} />
|
||||
<meta property='og:image' content={dataURL(normal)} />
|
||||
<meta property='og:image' content={dataURL('/r')} />
|
||||
<meta property='twitter:card' content='summary_large_image' />
|
||||
<title>{image.file}</title>
|
||||
</Head>
|
||||
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
minHeight='100vh'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<img src={dataURL(normal)} alt={dataURL(normal)}/>
|
||||
<img src={dataURL('/r')} alt={dataURL('/r')} id='image_content' />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
@@ -41,51 +56,89 @@ export default function EmbeddedImage({ image, title, username, color, normal, e
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
const id = context.params.id[1];
|
||||
const route = context.params.id[0];
|
||||
if (route !== config.uploader.embed_route.substr(1)) return {
|
||||
notFound: true
|
||||
};
|
||||
const routes = [config.uploader.route.substring(1), config.urls.route.substring(1)];
|
||||
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 };
|
||||
|
||||
const image = await prisma.image.findFirst({
|
||||
where: {
|
||||
file: id
|
||||
},
|
||||
select: {
|
||||
file: true,
|
||||
mimetype: true,
|
||||
userId: true
|
||||
}
|
||||
});
|
||||
return {
|
||||
props: {},
|
||||
redirect: {
|
||||
destination: url.destination,
|
||||
},
|
||||
};
|
||||
|
||||
if (!image) return {
|
||||
notFound: true
|
||||
};
|
||||
} else {
|
||||
const image = await prisma.image.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ file: id },
|
||||
{ invisible: { invis: id } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
mimetype: true,
|
||||
id: true,
|
||||
file: true,
|
||||
invisible: true,
|
||||
userId: true,
|
||||
embed: true,
|
||||
created_at: true,
|
||||
},
|
||||
});
|
||||
if (!image) return { notFound: true };
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
select: {
|
||||
embedTitle: true,
|
||||
embedColor: true,
|
||||
username: true
|
||||
},
|
||||
where: {
|
||||
id: image.userId
|
||||
}
|
||||
});
|
||||
|
||||
if (!image.mimetype.startsWith('image')) return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: `${config.uploader.route}/${image.file}`,
|
||||
}
|
||||
};
|
||||
const user = await prisma.user.findFirst({
|
||||
select: {
|
||||
embedTitle: true,
|
||||
embedColor: true,
|
||||
embedSiteName: true,
|
||||
username: true,
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
id: image.userId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
image,
|
||||
title: user.embedTitle,
|
||||
color: user.embedColor,
|
||||
username: user.username,
|
||||
normal: config.uploader.route,
|
||||
embed: config.uploader.embed_route
|
||||
//@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());
|
||||
if (prismRender) return {
|
||||
redirect: {
|
||||
destination: `/code/${image.file}`,
|
||||
permanent: true,
|
||||
},
|
||||
};
|
||||
|
||||
if (!image.mimetype.startsWith('image')) {
|
||||
const { default: datasource } = await import('lib/ds');
|
||||
|
||||
const data = await datasource.get(image.file);
|
||||
if (!data) return { notFound: true };
|
||||
|
||||
context.res.end(data);
|
||||
return { props: {} };
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
props: {
|
||||
image,
|
||||
user,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,34 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import Head from 'next/head';
|
||||
import Theming from 'components/Theming';
|
||||
import { useStore } from 'lib/redux/store';
|
||||
import ZiplineTheming from 'components/Theming';
|
||||
|
||||
export default function MyApp({ Component, pageProps }) {
|
||||
const store = useStore();
|
||||
|
||||
React.useEffect(() => {
|
||||
const jssStyles = document.querySelector('#jss-server-side');
|
||||
if (jssStyles) jssStyles.parentElement.removeChild(jssStyles);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Head>
|
||||
<title>{Component.title}</title>
|
||||
<meta name='description' content='Zipline' />
|
||||
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width' />
|
||||
</Head>
|
||||
<Theming
|
||||
Component={Component}
|
||||
pageProps={pageProps}
|
||||
/>
|
||||
<ZiplineTheming Component={Component} pageProps={pageProps} />
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
MyApp.propTypes = {
|
||||
Component: PropTypes.elementType.isRequired,
|
||||
pageProps: PropTypes.object.isRequired,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react';
|
||||
import Document, { Html, Head, Main, NextScript } from 'next/document';
|
||||
import { createGetInitialProps } from '@mantine/next';
|
||||
|
||||
const getInitialProps = createGetInitialProps();
|
||||
|
||||
class MyDocument extends Document {
|
||||
static async getInitialProps(ctx) {
|
||||
const initialProps = await Document.getInitialProps(ctx);
|
||||
return { ...initialProps };
|
||||
}
|
||||
static getInitialProps = getInitialProps;
|
||||
|
||||
render() {
|
||||
return (
|
||||
@@ -22,4 +22,4 @@ class MyDocument extends Document {
|
||||
}
|
||||
}
|
||||
|
||||
export default MyDocument;
|
||||
export default MyDocument;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user