Compare commits

...

115 Commits

Author SHA1 Message Date
diced
7963bdd1e4 feat(v3.4.6): version & fixes 2022-06-20 17:40:36 -07:00
diced
195c57edc3 refactor(server): clean up server code 2022-06-20 17:09:52 -07:00
diced
4442c85dc1 fix(server): prisma migrations dont exit (high cpu) 2022-06-20 17:08:43 -07:00
diced
5bcac2a2b0 refactor(readme): more info 2022-06-20 15:07:15 -07:00
diced
5303b67d11 fix(pages): add blur to password modal 2022-06-20 14:35:26 -07:00
diced
af59e9abb8 fix(pages): videos supported on embeded uploads 2022-06-20 14:33:10 -07:00
diced
fb098c9147 feat(page): add preview on hover & progress bar 2022-06-20 14:12:57 -07:00
diced
739974bef4 refactor(config): move into directory 2022-06-20 10:55:43 -07:00
diced
d21e48a1a3 fix(api): improve ratelimits 2022-06-20 10:49:24 -07:00
diced
8fea0cbe77 refactor: clean up datasource stuff 2022-06-20 10:25:22 -07:00
diced
1e2b8efb13 feat(v3.4.5): version 2022-06-19 17:46:45 -07:00
diced
8495963094 feat(v3.4.5): exporting images and more stuff 2022-06-19 17:46:20 -07:00
diced
06d1c0bc3b fix(api): make delete user with images actually delete their images from the datasource 2022-06-19 17:26:52 -07:00
diced
5965c2e237 fix(config): extention -> extension 2022-06-19 16:44:55 -07:00
diced
fb34dfadb0 fix(config): make endpoint nullable 2022-06-18 13:47:59 -07:00
diced
13b0ac737b feat(datasource): s3 path styles 2022-06-18 13:39:12 -07:00
diced
300430b3ec Merge branch 'trunk' of github.com:diced/zipline into trunk 2022-06-18 12:38:42 -07:00
NebulaBC
cf6f154e6e fix: add env vars for s3 endpoint (#153) 2022-06-17 19:05:20 -07:00
diced
2ddf8c0cdb fix(api): password protected images wont show up on root 2022-06-17 15:35:29 -07:00
NebulaBC
2a402f77b5 feat(api): S3 endpoint support (#152)
* S3 endpoint support

Adding endpoint support to S3 allows for other S3-compatible uploaders to be used

* Fix formatting error
2022-06-17 14:29:34 -07:00
Han Cen
7b2c31658a feat(api): root uploader route (#150) 2022-06-17 14:20:21 -07:00
Han Cen
7a91a60af9 fix: fix build (#149) 2022-06-17 08:35:53 -07:00
diced
bfa6c70bf3 chore(deps): update stuff 2022-06-16 14:22:26 -07:00
Jonathan
73eff05180 feat: use yarn v3 (#136)
* feat: use yarn v3

* chore: bump yarn to 3.2.1
2022-06-06 16:38:15 -07:00
relaxtakenotes
74f3b3f13d fix: image width not being set properly (#143)
* Fix image width not being set properly

Sometimes it got set to 0 because the original image wasn't loaded yet.

* fix: eslint

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2022-06-04 22:18:07 -07:00
diced
181833d768 Merge branch 'trunk' of github.com:diced/zipline into trunk 2022-06-04 22:05:26 -07:00
diced
be9523304a feat(v3.4.4): fix many bugs and password protected uploads 2022-06-04 22:05:08 -07:00
dicedtomato
b26fef3ad4 fix(docker): add restart policy for postgres 2022-03-26 20:37:02 +00:00
diced
9f86674bbe fix: update security policy 2022-03-14 20:31:18 -07:00
diced
095e57a037 fix(actions): arm -> arm64 2022-03-14 20:22:39 -07:00
diced
66a8e3bb79 feat: arm docker-compose file 2022-03-13 20:05:41 -07:00
diced
473137abdf fix(actions): fix arm action path 2022-03-13 19:36:28 -07:00
diced
740f1605e7 fix(actions): maybe fix actions 2022-03-13 19:30:37 -07:00
diced
0922ec020e fix: revert to node 16 on actions 2022-03-13 19:27:32 -07:00
diced
dbe8291f55 fix(actions): maybe fix arm action 2022-03-13 19:26:33 -07:00
diced
9dcc16277e refactor(actions): update to v2 of build-push-action & push arm image 2022-03-13 19:25:11 -07:00
diced
aa611fa6ba feat(v3.4.3): cleanup, fix memory leak, arm support 2022-03-13 19:13:18 -07:00
diced
083040e300 feat(v3.4.2): random domain selection #129 2022-03-03 17:52:34 -08:00
diced
99e92e4594 feat(v3.4.1): datasource api, for S3 functionality 2022-03-02 22:04:56 -08:00
diced
870f6e88b1 fix(prisma): add removal of custom theme migration 2022-02-26 17:27:37 -08:00
dicedtomato
16d2014bfb feat(v3.4.0): switch from Material-UI to Mantine! (#127) 2022-02-26 17:19:02 -08:00
diced
4d9a22e82c fix(api): data not defined 2022-02-21 09:28:32 -08:00
diced
42d77e445b feat(v3.3.2): image formats 2022-02-21 09:27:10 -08:00
diced
6506846207 fix: cleanup 2022-02-21 09:26:26 -08:00
diced
2b9af0e0de feat(api): formats for uploaded images 2022-02-20 22:01:31 -08:00
diced
762d2927f7 Merge branch 'trunk' of github.com:diced/zipline into trunk 2022-02-19 20:17:53 -08:00
diced
d9561f3b12 feat(v3.3.1): bug fixes and new features 2022-02-19 20:17:02 -08:00
dicedtomato
dde24848d4 fix: domains in readme 2022-02-17 16:31:12 -08:00
diced
e786482902 fix: multiple issues & new features 2022-02-12 20:35:36 -08:00
diced
4e64922b70 feat(v3.3): release 3.3 2022-01-03 19:00:51 -08:00
diced
15042b16d1 feat(v3.3): ctrl+v to upload image 2022-01-03 19:00:20 -08:00
diced
5e4c4fc6c9 feat(v3.3): faster stats 2022-01-03 15:56:33 -08:00
diced
7194c53891 feat(v3.3): ratelimit 2022-01-03 15:17:47 -08:00
Nguyen Thanh Quang
7eff77ccc4 refactor(api): cors duplication (#109)
* refactor(api): cors duplication

* refactor(middleware): moved content-type setter to top
2021-11-27 15:00:18 -08:00
Nguyen Thanh Quang
1b78ffaa91 fix(prisma): make sure migrations are migrated in the first run (#105)
* fix(prisma): make sure migrations are migrated in the first run

* chore: removed redundant parentheses
2021-11-27 14:39:57 -08:00
dicedtomato
8e8bfd68d1 Update README.md 2021-11-23 18:31:26 -08:00
diced
b029505cdd feat(api): add cors 2021-11-04 17:09:18 -07:00
Kyle
c5c862bee3 fix: readme links (#104) 2021-10-03 11:42:27 -07:00
Nguyen Thanh Quang
3c38d008f1 fix(config): updated example config file (#103) 2021-10-03 11:41:50 -07:00
diced
dc52b00a00 feat(v3.2.5): update mui & embed vars 2021-10-02 20:16:23 -07:00
diced
b5d2e7040e fix: multi 1000 to expires 2021-09-25 18:03:06 -07:00
diced
5818440721 feat(pages): add create url 2021-09-25 18:00:00 -07:00
diced
f1c46da47d feat(pages): add urls page 2021-09-25 17:30:23 -07:00
diced
212c69d303 fix: add comma dangles 2021-09-25 09:39:51 -07:00
diced
9e4152e298 fix: github actions build 2021-09-24 20:41:26 -07:00
diced
307f023e47 fix: github actions build 2021-09-24 20:39:43 -07:00
diced
3451bd8762 feat(v3.2.4): url shortenning 2021-09-24 20:31:45 -07:00
diced
a9d0be8aae fix: revert arm stuff 2021-09-18 21:34:02 -07:00
diced
d83f720631 fix(actions): add custom prisma engines 2021-09-18 21:28:24 -07:00
diced
1f3d396296 fix(actions): make action use v2 2021-09-18 20:59:03 -07:00
diced
48f771f344 fix(actions): make action use v2 2021-09-18 20:42:47 -07:00
diced
555bc6aa26 fix(docker): make action target linux/arm64 2021-09-18 20:33:25 -07:00
diced
8bd0eaac1e fix(docker): make action target linux/arm64 2021-09-18 20:19:50 -07:00
diced
3280c77002 fix(docker): make action target linux/arm64 2021-09-18 20:18:04 -07:00
diced
b39743a53a fix(docker): make action target linux/arm64 2021-09-18 20:16:33 -07:00
diced
9a73da56e9 feat(docker): add arm64 compatible images 2021-09-18 20:10:22 -07:00
diced
c9b0d2664f feat(v3.2.3): new config validation 2021-09-17 21:38:24 -07:00
dicedtomato
6063c9efac Update README.md 2021-09-17 20:40:38 -07:00
diced
dd6f192d4a fix: many things 2021-09-17 20:39:20 -07:00
diced
d956f4ed3d fix(api): fix recent images showing other users images 2021-09-12 21:31:43 -07:00
diced
4728258750 feat: v3.2.2 new file management & viewing 2021-09-12 21:17:27 -07:00
diced
ece3e16459 fix(api): fixed being able to override user (#98) 2021-09-08 20:56:47 -07:00
Nguyen Thanh Quang
9208dbe2f3 fix(api): fixed being able to override user (#98)
* fix(api): fixed being able to override user

* Update index.ts
2021-09-08 20:56:10 -07:00
diced
636de18642 feat(pages): add recent images to dashboard 2021-09-06 15:58:01 -07:00
diced
ee48456291 fix(api): change cookie max-age from 10m seconds to 1 week 2021-09-06 15:32:39 -07:00
diced
a06d5ffaed fix(api): sort images count and types count 2021-09-06 15:22:36 -07:00
diced
606821a2c0 fix(components): add skeleton placeholders to dashboard 2021-09-06 14:54:25 -07:00
diced
5c980c21e5 feat(api): allow bulk uploading (#97) 2021-09-05 11:23:52 -07:00
diced
771cc380df fix(api): maybe fix default user not being created 2021-09-05 09:14:19 -07:00
diced
38217870fe fix(api): maybe fix default user not being created 2021-09-05 09:13:13 -07:00
dicedtomato
5b82c96a43 Update README.md 2021-09-04 15:01:08 -07:00
diced
6f5f9869ad refactor(assets): update assets to v3 2021-09-04 14:42:38 -07:00
diced
b29bfeb8b1 fix(api): new way to handle non-embedded images to overwrite nextjs & supports discord now 2021-09-03 17:20:43 -07:00
diced
cb40559e49 fix(server): some changes 2021-09-03 17:03:47 -07:00
diced
90c72f7ffe fix(api): small images height strech fix 2021-09-03 16:50:14 -07:00
diced
002bd2e6f7 refactor(api): redirect non-embedded images to /r 2021-09-03 16:40:50 -07:00
diced
7b44f17a64 refactor(api): /raw -> /r 2021-09-03 16:26:04 -07:00
diced
b5c83f92e3 fix(api): fix images not showing 2021-09-02 21:46:10 -07:00
diced
51b4d64a93 fix(build): fix some docker stuff 2021-09-02 21:27:12 -07:00
diced
62c9e0a22f feat(api): add support for invisible images 2021-09-02 21:00:16 -07:00
diced
3daac34d3e feat(api): add support for invisible images 2021-09-02 20:59:30 -07:00
dependabot[bot]
d80d5d1632 build(deps): bump next from 11.1.0 to 11.1.1 (#95)
Bumps [next](https://github.com/vercel/next.js) from 11.1.0 to 11.1.1.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v11.1.0...v11.1.1)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-09-02 06:51:13 -07:00
diced
912f716362 feat(api): add support for zws images 2021-08-30 20:56:34 -07:00
diced
16ecdf41af feat(components): added sharex config generator (#93) 2021-08-30 13:58:06 -07:00
Nguyen Thanh Quang
f0bb6b08fa feat(components): added sharex config generator (#93) 2021-08-30 07:59:08 -07:00
Nguyen Thanh Quang
efb4e2ce9a feat(sharex): added sharex config generator 2021-08-30 20:19:12 +07:00
diced
03238d10bf fix(config): notify which vars are missing 2021-08-28 21:41:04 -07:00
diced
e71590b9fb fix(config): new opts: admin_limit, user_limit, disabled_extensions (#68) 2021-08-28 21:02:04 -07:00
diced
4728f1cc46 fix(api): accidently sending images as a part of the user object 2021-08-28 19:37:38 -07:00
diced
794778dee2 feat(config): database section removed 2021-08-28 11:32:09 -07:00
Nguyen Thanh Quang
b5e882f07e feat(themes): added dracula theme (#92)
* added dracula theme

* change border color from white

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2021-08-27 15:33:32 -07:00
dicedtomato
e7c58a4847 licence 2021-08-27 15:24:37 -07:00
dicedtomato
bdb44db25e security policy 2021-08-27 15:23:28 -07:00
diced
e8b82ffe62 feat(api): image favoriting (#67) 2021-08-27 13:48:22 -07:00
diced
53c53c009e fix(components): white text on links when light theme 2021-08-26 21:41:30 -07:00
143 changed files with 13935 additions and 8409 deletions

View File

@@ -1,20 +0,0 @@
{
"presets": [
"next/babel"
],
"plugins": [
[
"babel-plugin-transform-imports",
{
"@material-ui/core": {
"transform": "@material-ui/core/${member}",
"preventFullImport": true
},
"@material-ui/icons": {
"transform": "@material-ui/icons/${member}",
"preventFullImport": true
}
}
]
]
}

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
.next/
uploads/
.git/
.yarn/*
!.yarn/releases
!.yarn/plugins

View File

@@ -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
View 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"
}
}

View File

@@ -23,12 +23,11 @@ jobs:
key: ${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
- name: Create mock config
run: echo -e "[uploader]\nroute = '/u'\nembed_route = '/a'\nlength = 6\ndirectory = './uploads'" > config.toml
run: echo -e "[core]\nsecret = '12345678'\ndatabase_url = 'postgres://postgres:postgres@postgres/postgres'\n[uploader]\nroute = '/u'\ndirectory = './uploads'\n[urls]\nroute = '/go'" > config.toml
- name: Install dependencies
if: steps.cache-restore.outputs.cache-hit != 'true'
run: yarn install
- name: Build
run: yarn build
run: yarn build

41
.github/workflows/docker-arm.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: 'CD: Push ARM64 Docker Images'
on:
push:
branches: [ trunk ]
paths:
- 'src/**'
- 'server/**'
- 'prisma/**'
- '.github/**'
workflow_dispatch:
jobs:
push_to_ghcr:
name: Push Image to GitHub Packages
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Setup QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Login to Github Packages
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
uses: docker/build-push-action@v2
with:
file: ./Dockerfile-arm
platforms: linux/arm64
push: true
tags: ghcr.io/diced/zipline/arm64:trunk

View File

@@ -7,6 +7,7 @@ on:
- 'src/**'
- 'server/**'
- 'prisma/**'
- '.github/**'
workflow_dispatch:
jobs:
@@ -17,28 +18,31 @@ jobs:
- name: Check out the repo
uses: actions/checkout@v2
- name: Push to GitHub Packages
uses: docker/build-push-action@v1
- name: Setup QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Login to Github Packages
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
registry: docker.pkg.github.com
repository: diced/zipline/zipline
dockerfile: Dockerfile
tag_with_ref: true
push_to_dockerhub:
name: Push Image to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Push to Docker Hub
uses: docker/build-push-action@v1
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: diced/zipline
dockerfile: Dockerfile
tag_with_ref: true
- name: Build Docker Image
uses: docker/build-push-action@v2
with:
push: true
tags: |
ghcr.io/diced/zipline/zipline:trunk
ghcr.io/diced/zipline/amd64:trunk
diced/zipline:trunk

9
.gitignore vendored
View File

@@ -5,6 +5,11 @@
/.pnp
.pnp.js
# yarn
.yarn/*
!.yarn/releases
!.yarn/plugins
# testing
/coverage
@@ -18,6 +23,7 @@
# misc
.DS_Store
*.pem
.idea
# debug
npm-debug.log*
@@ -36,4 +42,5 @@ yarn-error.log*
# zipline
config.toml
uploads/
data.db*
dist/
docker-compose.local.yml

1
.husky/.gitignore vendored
View File

@@ -1 +0,0 @@
_

View File

@@ -1,4 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn commitlint --edit $1

File diff suppressed because one or more lines are too long

786
.yarn/releases/yarn-3.2.1.cjs vendored Normal file

File diff suppressed because one or more lines are too long

7
.yarnrc.yml Normal file
View File

@@ -0,0 +1,7 @@
nodeLinker: node-modules
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: "@yarnpkg/plugin-interactive-tools"
yarnPath: .yarn/releases/yarn-3.2.1.cjs

View File

@@ -1,31 +1,47 @@
FROM node:16-alpine3.11 AS builder
FROM node:16-alpine AS deps
WORKDIR /build
COPY .yarn .yarn
COPY package.json yarn.lock .yarnrc.yml ./
RUN apk add --no-cache libc6-compat
RUN yarn install --immutable
FROM node:16-alpine AS builder
WORKDIR /build
COPY --from=deps /build/node_modules ./node_modules
COPY src ./src
COPY server ./server
COPY scripts ./scripts
COPY prisma ./prisma
COPY .yarn .yarn
COPY package.json yarn.lock .yarnrc.yml esbuild.config.js next.config.js next-env.d.ts zip-env.d.ts tsconfig.json ./
COPY package.json yarn.lock next.config.js next-env.d.ts zip-env.d.ts tsconfig.json ./
RUN yarn install
# create a mock config.toml to spoof next build!
RUN echo -e "[uploader]\nroute = '/u'\nembed_route = '/a'\nlength = 6\ndirectory = './uploads'" > config.toml
ENV ZIPLINE_DOCKER_BUILD 1
ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
FROM node:16-alpine3.11 AS runner
FROM node:16-alpine AS runner
WORKDIR /zipline
COPY --from=builder /build/node_modules ./node_modules
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 zipline
RUN adduser --system --uid 1001 zipline
COPY --from=builder --chown=zipline:zipline /build/.next ./.next
COPY --from=builder --chown=zipline:zipline /build/dist ./dist
COPY --from=builder --chown=zipline:zipline /build/node_modules ./node_modules
COPY --from=builder /build/next.config.js ./next.config.js
COPY --from=builder /build/src ./src
COPY --from=builder /build/server ./server
COPY --from=builder /build/scripts ./scripts
COPY --from=builder /build/prisma ./prisma
COPY --from=builder /build/.next ./.next
COPY --from=builder /build/tsconfig.json ./tsconfig.json
COPY --from=builder /build/package.json ./package.json
CMD ["node", "server"]
USER zipline
CMD ["node", "dist/server"]

46
Dockerfile-arm Normal file
View File

@@ -0,0 +1,46 @@
FROM node:16 AS deps
WORKDIR /build
COPY .yarn .yarn
COPY package.json yarn.lock .yarnrc.yml ./
RUN yarn install --immutable
FROM node:16 AS builder
WORKDIR /build
COPY --from=deps /build/node_modules ./node_modules
COPY src ./src
COPY scripts ./scripts
COPY prisma ./prisma
COPY .yarn .yarn
COPY package.json yarn.lock .yarnrc.yml esbuild.config.js next.config.js next-env.d.ts zip-env.d.ts tsconfig.json ./
ENV ZIPLINE_DOCKER_BUILD 1
ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
FROM node:16 AS runner
WORKDIR /zipline
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 zipline
RUN adduser --system --uid 1001 zipline
COPY --from=builder --chown=zipline:zipline /build/.next ./.next
COPY --from=builder --chown=zipline:zipline /build/dist ./dist
COPY --from=builder --chown=zipline:zipline /build/node_modules ./node_modules
COPY --from=builder /build/next.config.js ./next.config.js
COPY --from=builder /build/src ./src
COPY --from=builder /build/scripts ./scripts
COPY --from=builder /build/prisma ./prisma
COPY --from=builder /build/tsconfig.json ./tsconfig.json
COPY --from=builder /build/package.json ./package.json
USER zipline
CMD ["node", "dist/server"]

View File

@@ -1,3 +0,0 @@
prisma/migrations
node_modules
.next

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 dicedtomato
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

124
README.md
View File

@@ -1,25 +1,113 @@
<p align="center"><img src="https://raw.githubusercontent.com/diced/zipline/trunk/public/zipline_small.png"/></p>
![Version](https://img.shields.io/github/package-json/v/diced/zipline)
![LICENCE](https://img.shields.io/github/license/diced/zipline)
[![Discord](https://img.shields.io/discord/729771078196527176)](https://discord.gg/EAhCRfGxCF)
![Stars](https://img.shields.io/github/stars/diced/zipline)
![GitHub repo size](https://img.shields.io/github/repo-size/diced/zipline)
![GitHub last commit (branch)](https://img.shields.io/github/last-commit/diced/zipline/trunk)
<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!
![Build](https://img.shields.io/github/workflow/status/diced/zipline/CD:%20Push%20Docker%20Images?logo=github&style=flat-square)
![Stars](https://img.shields.io/github/stars/diced/zipline?logo=github&style=flat-square)
![Version](https://img.shields.io/github/package-json/v/diced/zipline?logo=git&logoColor=white&style=flat-square)
![GitHub last commit (branch)](https://img.shields.io/github/last-commit/diced/zipline/trunk?logo=git&logoColor=white&style=flat-square)
[![Discord](https://img.shields.io/discord/729771078196527176?color=%23777ed3&label=discord&logo=discord&logoColor=white&style=flat-square)](https://discord.gg/EAhCRfGxCF)
</div>
## Features
- Configurable
- Fast
- Built with Next.js & React
- Token protected uploading
- Easy setup instructions on [docs](https://zipline.diced.me) (One command install `docker-compose up`)
- Image uploading
- Password Protected Uploads
- URL shortening
- Text uploading
- URL Formats (uuid, dates, random alphanumeric, original name, zws)
- Discord embeds (OG metadata)
- Gallery viewer, and multiple file format support
- Code highlighting
- Easy setup instructions on [docs](https://zipl.vercel.app/) (One command install `docker-compose up -d`)
# Installing
# Usage
[See how to install here](https://zipline.diced.me/docs/getting-started)
## Install & run with Docker
This section requires [Docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/).
```shell
git clone https://github.com/diced/zipline
cd zipline
docker-compose up -d
```
### After installing
After installing, please edit the `docker-compose.yml` file and find the line that says `SECRET=changethis` and replace `changethis` with a random string.
Ways you could generate the string could be from a password managers generator, or you could just slam your keyboard and hope for the best.
## Building & running from source
This section requires [nodejs](https://nodejs.org), [yarn](https://yarnpkg.com/) or [npm](https://npmjs.com).
```shell
git clone https://github.com/diced/zipline
cd zipline
# npm install
yarn install
# npm run build
yarn build
# npm start
yarn start
```
# NGINX Proxy
This section requires [nginx](https://nginx.org/).
```nginx
server {
listen 80 default_server;
client_max_body_size 100M;
server_name <your domain (optional)>;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
# Website
The default port is `3000`, once you have accessed it you can see a login screen. The default credentials are "administrator" and "password". Once you login please immediately change the details to something more secure. You can do this by clicking on the top right corner where it says "administrator" with a gear icon and clicking Manage Account.
# ShareX (Windows)
This section requires [ShareX](https://www.getsharex.com/).
After navigating to Zipline, click on the top right corner where it says your username and click Manage Account. Scroll down to see "ShareX Config", select the one you would prefer using. After this you can import the .sxcu into sharex. [More information here](https://zipl.vercel.app/docs/uploaders/sharex)
# Flameshot (Linux)
This section requires [Flameshot](https://www.flameshot.org/), [jq](https://stedolan.github.io/jq/) and xsel.
To upload files using flameshot we will use a script. Replace $TOKEN and $HOST with your own values, you probably know how to do this if you use linux.
```shell
DATE=$(date '+%h_%Y_%d_%I_%m_%S.png');
flameshot gui -r > ~/Pictures/$DATE;
curl -H "Content-Type: multipart/form-data" -H "authorization: $TOKEN" -F file=@$1 $HOST/api/upload | jq -r 'files[0].url' | xsel -ib
```
# Contributing
## Bug reports
Create an issue on GitHub, please include the following:
* The steps to reproduce the bug
* Logs of Zipline
* The version of Zipline
* Your OS & Browser including server OS
* What you were expecting to see
## Feature requests
Create an issue on GitHub, please include the following:
* Breif explanation of the feature in the title (very breif please)
* How it would work (detailed, but optional)
## Pull Requests (contributions to the codebase)
Create a pull request on GitHub. If your PR does not pass the action checks, then please fix the errors. If your PR was submitted before a release, and I have pushed a new release, please make sure to update your PR to reflect any changes, usually this is handled by GitHub.

12
SECURITY.md Normal file
View File

@@ -0,0 +1,12 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 3.4.4 | :white_check_mark: |
| < 3 | :x: |
| < 2 | :x: |
## Reporting a Vulnerability
Report a Vulnerability by issuing a bug report, with exact details with how the vulnerability happened, what "exploits" can happen, and possible fixes (optional). Vulnerability reports are treated with high priority and will be resolved most of the time quickly.

View File

@@ -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'
],
],
},
};

View File

@@ -1,15 +1,19 @@
[core]
secure = true
secret = 'some secret'
secret = 'changethis'
host = '0.0.0.0'
port = 3000
database_url = 'postgres://postgres:postgres@postgres/postgres'
[database]
type = 'psql'
url = 'postgres://postgres:postgres@postgres/postgres'
[urls]
route = '/go'
length = 6
[uploader]
route = '/u'
embed_route = '/a'
length = 6
directory = './uploads'
directory = './uploads'
user_limit = 104900000 # 100mb
admin_limit = 104900000 # 100mb
disabled_extensions = ['jpg']

46
docker-compose.arm.yml Normal file
View File

@@ -0,0 +1,46 @@
version: '3'
services:
postgres:
image: postgres
restart: always
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DATABASE=postgres
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 10s
timeout: 5s
retries: 5
zipline:
image: ghcr.io/diced/zipline/arm64:trunk
ports:
- '3000:3000'
restart: unless-stopped
environment:
- SECURE=false
- SECRET=changethis
- HOST=0.0.0.0
- PORT=3000
- DATASOURCE_TYPE=local
- DATASOURCE_LOCAL_DIRECTORY=./uploads
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
- UPLOADER_ROUTE=/u
- UPLOADER_EMBED_ROUTE=/a
- UPLOADER_LENGTH=6
- UPLOADER_ADMIN_LIMIT=104900000
- UPLOADER_USER_LIMIT=104900000
- UPLOADER_DISABLED_EXTS=
- URLS_ROUTE=/go
- URLS_LENGTH=6
volumes:
- '$PWD/uploads:/zipline/uploads'
- '$PWD/public:/zipline/public'
depends_on:
- 'postgres'
volumes:
pg_data:

48
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,48 @@
version: '3'
services:
postgres:
image: postgres
restart: always
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DATABASE=postgres
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 10s
timeout: 5s
retries: 5
zipline:
build:
context: .
dockerfile: Dockerfile
ports:
- '3000:3000'
restart: unless-stopped
environment:
- SECURE=false
- SECRET=changethis
- HOST=0.0.0.0
- PORT=3000
- DATASOURCE_TYPE=local
- DATASOURCE_LOCAL_DIRECTORY=./uploads
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
- UPLOADER_ROUTE=/u
- UPLOADER_EMBED_ROUTE=/a
- UPLOADER_LENGTH=6
- UPLOADER_ADMIN_LIMIT=104900000
- UPLOADER_USER_LIMIT=104900000
- UPLOADER_DISABLED_EXTS=
- URLS_ROUTE=/go
- URLS_LENGTH=6
volumes:
- '$PWD/uploads:/zipline/uploads'
- '$PWD/public:/zipline/public'
depends_on:
- 'postgres'
volumes:
pg_data:

View File

@@ -2,11 +2,12 @@ version: '3'
services:
postgres:
image: postgres
environment:
restart: always
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DATABASE=postgres
volumes:
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
@@ -15,24 +16,28 @@ services:
retries: 5
zipline:
image: diced/zipline:trunk
image: ghcr.io/diced/zipline/zipline:trunk
ports:
- '3000:3000'
restart: unless-stopped
environment:
restart: always
environment:
- SECURE=false
- SECRET=changethis
- HOST=0.0.0.0
- PORT=3000
- DATABASE_TYPE=psql
- DATASOURCE_TYPE=local
- DATASOURCE_DIRECTORY=./uploads
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
- UPLOADER_ROUTE=/u
- UPLOADER_EMBED_ROUTE=/a
- UPLOADER_LENGTH=6
- UPLOADER_DIRECTORY=./uploads
- UPLOADER_ADMIN_LIMIT=104900000
- UPLOADER_USER_LIMIT=104900000
- UPLOADER_DISABLED_EXTS=
- URLS_ROUTE=/go
- URLS_LENGTH=6
volumes:
- '$PWD/uploads:/zipline/uploads'
- '$PWD/prisma:/zipline/prisma'
- '$PWD/public:/zipline/public'
depends_on:
- 'postgres'

40
esbuild.config.js Normal file
View File

@@ -0,0 +1,40 @@
const esbuild = require('esbuild');
const { existsSync } = require('fs');
const { rm } = require('fs/promises');
(async () => {
const watch = process.argv[2] === '--watch';
if (existsSync('./dist')) {
await rm('./dist', { recursive: true });
}
await esbuild.build({
tsconfig: 'tsconfig.json',
outdir: 'dist',
bundle: false,
platform: 'node',
treeShaking: true,
entryPoints: [
'src/server/index.ts',
'src/server/util.ts',
'src/lib/logger.ts',
'src/lib/config.ts',
'src/lib/config/Config.ts',
'src/lib/config/readConfig.ts',
'src/lib/config/validateConfig.ts',
'src/lib/datasources/Datasource.ts',
'src/lib/datasources/index.ts',
'src/lib/datasources/Local.ts',
'src/lib/datasources/S3.ts',
'src/lib/datasource.ts',
],
format: 'cjs',
resolveExtensions: ['.ts', '.js'],
write: true,
watch,
incremental: watch,
sourcemap: false,
minify: true,
});
})();

1
next-env.d.ts vendored
View File

@@ -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

View File

@@ -1,4 +1,14 @@
module.exports = {
reactStrictMode: true,
};
async redirects() {
return [
{
source: '/',
destination: '/dashboard',
permanent: true,
},
];
},
api: {
responseLimit: false,
},
};

View File

@@ -1,62 +1,68 @@
{
"name": "zip3",
"version": "3.2.0",
"name": "zipline",
"version": "3.4.6",
"license": "MIT",
"scripts": {
"prepare": "husky install",
"dev": "NODE_ENV=development node server",
"build": "npm-run-all build:schema build:next",
"dev": "node esbuild.config.js && REACT_EDITOR=code NODE_ENV=development node dist/server",
"build": "npm-run-all build:server build:schema build:next",
"build:server": "node esbuild.config.js",
"build:next": "next build",
"build:schema": "prisma generate --schema=prisma/schema.prisma",
"start": "node server",
"migrate:dev": "prisma migrate dev --create-only",
"start": "node dist/server",
"lint": "next lint",
"ts-node": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only",
"create-all-migrations": "node scripts/create-migrations",
"semantic-release": "semantic-release"
"docker:run": "docker-compose up -d",
"docker:down": "docker-compose down",
"docker:build-dev": "docker-compose --file docker-compose.dev.yml up --build"
},
"dependencies": {
"@emotion/react": "^11.4.0",
"@emotion/styled": "^11.3.0",
"@iarna/toml": "2.2.5",
"@material-ui/core": "^5.0.0-alpha.37",
"@material-ui/data-grid": "^4.0.0-alpha.32",
"@material-ui/icons": "^5.0.0-alpha.37",
"@material-ui/styles": "^5.0.0-alpha.35",
"@prisma/client": "^2.30.0",
"@reduxjs/toolkit": "^1.6.0",
"argon2": "^0.28.2",
"colorette": "^1.2.2",
"cookie": "^0.4.1",
"copy-to-clipboard": "^3.3.1",
"fecha": "^4.2.1",
"formik": "^2.2.9",
"multer": "^1.4.2",
"next": "11.1.0",
"prisma": "^2.30.0",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-dropzone": "^11.3.2",
"react-redux": "^7.2.4",
"redux": "^4.1.0",
"redux-thunk": "^2.3.0",
"yup": "^0.32.9"
"@mantine/core": "^4.2.9",
"@mantine/dropzone": "^4.2.9",
"@mantine/hooks": "^4.2.9",
"@mantine/modals": "^4.2.9",
"@mantine/next": "^4.2.9",
"@mantine/notifications": "^4.2.9",
"@mantine/prism": "^4.2.9",
"@modulz/radix-icons": "^4.0.0",
"@prisma/client": "^3.15.2",
"@prisma/migrate": "^3.15.2",
"@prisma/sdk": "^3.15.2",
"@reduxjs/toolkit": "^1.8.2",
"argon2": "^0.28.5",
"aws-sdk": "^2.1156.0",
"colorette": "^2.0.19",
"cookie": "^0.5.0",
"fecha": "^4.2.3",
"fflate": "^0.7.3",
"find-my-way": "^6.3.0",
"multer": "^1.4.5-lts.1",
"next": "^12.1.6",
"prisma": "^3.15.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.0.2",
"react-table": "^7.8.0",
"redux": "^4.2.0",
"redux-thunk": "^2.4.1",
"uuid": "^8.3.2",
"yup": "^0.32.11"
},
"devDependencies": {
"@commitlint/cli": "^12.1.4",
"@commitlint/config-conventional": "^12.1.4",
"@types/cookie": "^0.4.0",
"@types/multer": "^1.4.6",
"@types/cookie": "^0.5.1",
"@types/multer": "^1.4.7",
"@types/node": "^15.12.2",
"babel-plugin-transform-imports": "^2.0.0",
"eslint": "7.28.0",
"eslint-config-next": "11.0.0",
"husky": "^6.0.0",
"babel-plugin-import": "^1.13.5",
"esbuild": "^0.14.44",
"eslint": "^7.32.0",
"eslint-config-next": "12.1.6",
"npm-run-all": "^4.1.5",
"release": "^6.3.0",
"ts-node": "^10.0.0",
"typescript": "^4.3.2"
"ts-node": "^10.8.1",
"typescript": "^4.7.3"
},
"repository": {
"type": "git",
"url": "https://github.com/diced/workflow-testing.git"
}
}
"url": "https://github.com/diced/zipline.git"
},
"packageManager": "yarn@3.2.1"
}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Image" ADD COLUMN "favorite" BOOLEAN NOT NULL DEFAULT false;

View 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;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Image" ADD COLUMN "embed" BOOLEAN NOT NULL DEFAULT false;

View 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";

View 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;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Url" ADD COLUMN "vanity" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "embedSiteName" TEXT DEFAULT E'{image.file} • {user.name}';

View 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";

View 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")
);

View 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';

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "ImageFormat" ADD VALUE 'NAME';

View File

@@ -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";

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "domains" TEXT[];

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Image" ADD COLUMN "password" TEXT;

View File

@@ -0,0 +1,9 @@
/*
Warnings:
- You are about to drop the column `ratelimited` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "ratelimited",
ADD COLUMN "ratelimit" TIMESTAMP(3);

View File

@@ -8,32 +8,26 @@ generator client {
}
model User {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
username String
password String
token String
administrator Boolean @default(false)
systemTheme String @default("dark_blue")
customTheme Theme?
administrator Boolean @default(false)
systemTheme String @default("system")
embedTitle String?
embedColor String @default("#2f3136")
embedColor String @default("#2f3136")
embedSiteName String? @default("{image.file} • {user.name}")
ratelimit DateTime?
domains String[]
images Image[]
urls Url[]
}
model Theme {
id Int @id @default(autoincrement())
type String
primary String
secondary String
error String
warning String
info String
border String
mainBackground String
paperBackground String
user User @relation(fields: [userId], references: [id])
userId Int
enum ImageFormat {
UUID
DATE
RANDOM
NAME
}
model Image {
@@ -42,31 +36,42 @@ model Image {
mimetype String @default("image/png")
created_at DateTime @default(now())
views Int @default(0)
favorite Boolean @default(false)
embed Boolean @default(false)
password String?
invisible InvisibleImage?
format ImageFormat @default(RANDOM)
user User @relation(fields: [userId], references: [id])
userId Int
}
model InvisibleImage {
id Int
image Image @relation(fields: [id], references: [id])
invis String @unique
id Int @id @default(autoincrement())
invis String @unique
imageId Int @unique
image Image @relation(fields: [imageId], references: [id])
}
model Url {
id Int @id @default(autoincrement())
to String
created_at DateTime @default(now())
views Int @default(0)
invisible InvisibleUrl?
user User @relation(fields: [userId], references: [id])
userId Int
id String @id @unique
destination String
vanity String?
created_at DateTime @default(now())
views Int @default(0)
invisible InvisibleUrl?
user User @relation(fields: [userId], references: [id])
userId Int
}
model InvisibleUrl {
id Int
url Url @relation(fields: [id], references: [id])
id Int @id @default(autoincrement())
invis String @unique
urlId String @unique
url Url @relation(fields: [urlId], references: [id])
}
model Stats {
id Int @id @default(autoincrement())
created_at DateTime @default(now())
data Json
}

View File

@@ -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

View File

@@ -1,8 +0,0 @@
module.exports = {
branches: ['trunk'],
plugins: [
'@semantic-release/commit-analyzer',
'@semantic-release/github',
'@semantic-release/changelog'
]
};

View File

@@ -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
View 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',
};

View File

@@ -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();

View File

@@ -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',
};

View File

@@ -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));
});
};

View File

@@ -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);
}
}
})();

View File

@@ -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;
}
};

View File

@@ -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);
}
};

View File

@@ -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>
);
}

View File

@@ -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} />
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View 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>
);
}

View File

@@ -1,417 +1,333 @@
import React, { useState } from 'react';
import Link from 'next/link';
import useFetch from '../lib/hooks/useFetch';
import {
AppBar,
Box,
Divider,
Drawer,
IconButton,
List,
ListItem,
ListItemIcon,
ListItemText,
Toolbar,
Typography,
Button,
Menu,
MenuItem,
Paper,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Select,
} from '@material-ui/core';
import {
Menu as MenuIcon,
Home as HomeIcon,
AccountCircle as AccountIcon,
Image as ImageIcon,
Upload as UploadIcon,
ContentCopy as CopyIcon,
Autorenew as ResetIcon,
Logout as LogoutIcon,
PeopleAlt as UsersIcon,
Brush as BrushIcon
} from '@material-ui/icons';
import copy from 'copy-to-clipboard';
import Backdrop from './Backdrop';
import { friendlyThemeName, themes } from './Theming';
import { useRouter } from 'next/router';
import { useStoreDispatch } from 'lib/redux/store';
import { updateUser } from 'lib/redux/reducers/user';
import useFetch from 'hooks/useFetch';
import { CheckIcon, CopyIcon, Cross1Icon, FileIcon, GearIcon, HomeIcon, Link1Icon, ResetIcon, UploadIcon, PinRightIcon, PersonIcon, Pencil1Icon, MixerHorizontalIcon } from '@modulz/radix-icons';
import { AppShell, Burger, Divider, Group, Header, MediaQuery, Navbar, Paper, Popover, ScrollArea, Select, Text, ThemeIcon, Title, UnstyledButton, useMantineTheme, Box } from '@mantine/core';
import { useModals } from '@mantine/modals';
import { useNotifications } from '@mantine/notifications';
import { useClipboard } from '@mantine/hooks';
import { friendlyThemeName, themes } from './Theming';
function MenuItemLink(props) {
return (
<Link href={props.href} passHref>
<MenuItem {...props} />
</Link>
);
}
function MenuItem(props) {
return (
<UnstyledButton
sx={theme => ({
display: 'block',
width: '100%',
padding: 5,
borderRadius: theme.radius.sm,
color: props.color
? theme.fn.themeColor(props.color, theme.colorScheme === 'dark' ? 5 : 7)
: theme.colorScheme === 'dark'
? theme.colors.dark[0]
: theme.black,
'&:hover': {
backgroundColor: props.color
? theme.fn.rgba(
theme.fn.themeColor(props.color, theme.colorScheme === 'dark' ? 9 : 0),
theme.colorScheme === 'dark' ? 0.2 : 1
)
: theme.colorScheme === 'dark'
? theme.fn.rgba(theme.colors.dark[3], 0.35)
: theme.colors.gray[0],
},
})}
{...props}
>
<Group noWrap>
<Box sx={theme => ({
marginRight: theme.spacing.xs / 4,
paddingLeft: theme.spacing.xs / 2,
'& *': {
display: 'block',
},
})}>
{props.icon}
</Box>
<Text size='sm'>{props.children}</Text>
</Group>
</UnstyledButton>
);
}
const items = [
{
icon: <HomeIcon />,
text: 'Home',
link: '/dashboard'
link: '/dashboard',
},
{
icon: <ImageIcon />,
text: 'Images',
link: '/dashboard/images'
icon: <FileIcon />,
text: 'Files',
link: '/dashboard/files',
},
{
icon: <MixerHorizontalIcon />,
text: 'Stats',
link: '/dashboard/stats',
},
{
icon: <Link1Icon />,
text: 'URLs',
link: '/dashboard/urls',
},
{
icon: <UploadIcon />,
text: 'Upload',
link: '/dashboard/upload'
}
link: '/dashboard/upload',
},
];
const drawerWidth = 240;
function CopyTokenDialog({ open, setOpen, token }) {
const handleCopyToken = () => {
copy(token);
setOpen(false);
};
return (
<div>
<Dialog
open={open}
onClose={() => setOpen(false)}
>
<DialogTitle id='copy-dialog-title'>
Copy Token
</DialogTitle>
<DialogContent>
<DialogContentText id='copy-dialog-description'>
Make sure you don&apos;t share this token with anyone as they will be able to upload images on your behalf.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
<Button onClick={handleCopyToken} color='inherit'>
Copy
</Button>
</DialogActions>
</Dialog>
</div>
);
}
function ResetTokenDialog({ open, setOpen, setToken }) {
const handleResetToken = async () => {
const a = await useFetch('/api/user/token', 'PATCH');
if (a.success) setToken(a.success);
setOpen(false);
};
return (
<div>
<Dialog
open={open}
onClose={() => setOpen(false)}
>
<DialogTitle id='reset-dialog-title'>
Reset Token
</DialogTitle>
<DialogContent>
<DialogContentText id='reset-dialog-description'>
Once you reset your token, you will have to update any uploaders to use this new token.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
<Button onClick={handleResetToken} color='inherit'>
Reset
</Button>
</DialogActions>
</Dialog>
</div>
);
}
export default function Layout({ children, user, loading, noPaper }) {
const [systemTheme, setSystemTheme] = useState(user.systemTheme || 'dark_blue');
const [mobileOpen, setMobileOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState(null);
const [copyOpen, setCopyOpen] = useState(false);
const [resetOpen, setResetOpen] = useState(false);
export default function Layout({ children, user }) {
const [token, setToken] = useState(user?.token);
const [systemTheme, setSystemTheme] = useState(user.systemTheme ?? 'system');
const [opened, setOpened] = useState(false); // navigation open
const [open, setOpen] = useState(false); // manage acc dropdown
const router = useRouter();
const dispatch = useStoreDispatch();
const theme = useMantineTheme();
const modals = useModals();
const notif = useNotifications();
const clipboard = useClipboard();
const open = Boolean(anchorEl);
const handleClick = e => setAnchorEl(e.currentTarget);
const handleClose = (cmd: 'copy' | 'reset') => () => {
switch (cmd) {
case 'copy':
setCopyOpen(true);
break;
case 'reset':
setResetOpen(true);
break;
}
setAnchorEl(null);
};
const handleUpdateTheme = async (event: React.ChangeEvent<{ value: string }>) => {
const handleUpdateTheme = async value => {
const newUser = await useFetch('/api/user', 'PATCH', {
systemTheme: event.target.value || 'dark_blue'
systemTheme: value || 'dark_blue',
});
setSystemTheme(newUser.systemTheme);
dispatch(updateUser(newUser));
router.replace(router.pathname);
notif.showNotification({
title: `Theme changed to ${friendlyThemeName[value]}`,
message: '',
color: 'green',
icon: <Pencil1Icon />,
});
};
const drawer = (
<div>
<CopyTokenDialog open={copyOpen} setOpen={setCopyOpen} token={token} />
<ResetTokenDialog open={resetOpen} setOpen={setResetOpen} setToken={setToken} />
<Toolbar
sx={{
width: { xs: drawerWidth }
}}
>
<AppBar
position='fixed'
elevation={0}
sx={{
borderBottom: 1,
borderBottomColor: t => t.palette.divider,
display: { xs: 'none', sm: 'block' }
}}
>
<Toolbar>
<IconButton
color='inherit'
aria-label='open drawer'
edge='start'
onClick={() => setMobileOpen(true)}
sx={{ mr: 2, display: { sm: 'none' } }}
>
<MenuIcon />
</IconButton>
<Typography
variant='h5'
noWrap
component='div'
>
Zipline
</Typography>
{user && (
<Box sx={{ marginLeft: 'auto' }}>
<Button
color='inherit'
aria-expanded={open ? 'true' : undefined}
onClick={handleClick}
>
<AccountIcon />
</Button>
<Menu
id='zipline-user-menu'
anchorEl={anchorEl}
open={open}
onClose={handleClose(null)}
MenuListProps={{
'aria-labelledby': 'basic-button',
}}
>
<MenuItem disableRipple>
<Typography variant='h5'>
<b>{user.username}</b>
</Typography>
</MenuItem>
<Divider />
<Link href='/dashboard/manage'>
<a style={{ color: 'white', textDecoration: 'none' }}>
<MenuItem onClick={handleClose(null)}>
<AccountIcon sx={{ mr: 2 }} /> Manage Account
</MenuItem>
</a>
</Link>
<MenuItem onClick={handleClose('copy')}>
<CopyIcon sx={{ mr: 2 }} /> Copy Token
</MenuItem>
<MenuItem onClick={handleClose('reset')}>
<ResetIcon sx={{ mr: 2 }} /> Reset Token
</MenuItem>
<Link href='/auth/logout'>
<a style={{ color: 'white', textDecoration: 'none' }}>
<MenuItem onClick={handleClose(null)}>
<LogoutIcon sx={{ mr: 2 }} /> Logout
</MenuItem>
</a>
</Link>
<MenuItem>
<BrushIcon sx={{ mr: 2 }} />
<Select
variant='standard'
label='Theme'
value={systemTheme}
onChange={handleUpdateTheme}
fullWidth
>
{Object.keys(themes).map(t => (
<MenuItem value={t} key={t}>
{friendlyThemeName[t]}
</MenuItem>
))}
</Select>
</MenuItem>
</Menu>
</Box>
)}
</Toolbar>
</AppBar>
</Toolbar>
<Divider />
<List>
{items.map((item, i) => (
<Link key={i} href={item.link}>
<a href={item.link} style={{ color: 'white', textDecoration: 'none' }}>
<ListItem button>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItem>
</a>
</Link>
))}
{user && user.administrator && (
<Link href='/dashboard/users' passHref>
<a style={{ color: 'white', textDecoration: 'none' }}>
<ListItem button>
<ListItemIcon><UsersIcon /></ListItemIcon>
<ListItemText primary='Users' />
</ListItem>
</a>
</Link>
)}
</List>
</div>
);
const openResetToken = () => modals.openConfirmModal({
title: 'Reset Token',
centered: true,
overlayBlur: 3,
children: (
<Text size='sm'>
Once you reset your token, you will have to update any uploaders to use this new token.
</Text>
),
labels: { confirm: 'Reset', cancel: 'Cancel' },
onConfirm: async () => {
const a = await useFetch('/api/user/token', 'PATCH');
if (!a.success) {
setToken(a.success);
notif.showNotification({
title: 'Token Reset Failed',
message: a.error,
color: 'red',
icon: <Cross1Icon />,
});
} else {
notif.showNotification({
title: 'Token Reset',
message: 'Your token has been reset. You will need to update any uploaders to use this new token.',
color: 'green',
icon: <CheckIcon />,
});
}
const container = typeof window !== 'undefined' ? window.document.body : undefined;
modals.closeAll();
},
});
const openCopyToken = () => modals.openConfirmModal({
title: 'Copy Token',
centered: true,
overlayBlur: 3,
children: (
<Text size='sm'>
Make sure you don&apos;t share this token with anyone as they will be able to upload files on your behalf.
</Text>
),
labels: { confirm: 'Copy', cancel: 'Cancel' },
onConfirm: async () => {
clipboard.copy(token);
notif.showNotification({
title: 'Token Copied',
message: 'Your token has been copied to your clipboard.',
color: 'green',
icon: <CheckIcon />,
});
modals.closeAll();
},
});
return (
<Box sx={{ display: 'flex' }}>
<Backdrop open={loading} />
<AppShell
navbarOffsetBreakpoint='sm'
fixed
navbar={
<Navbar
p='md'
hiddenBreakpoint='sm'
hidden={!opened}
width={{ sm: 200, lg: 230 }}
>
<Navbar.Section
grow
component={ScrollArea}
ml={-10}
mr={-10}
sx={{ paddingLeft: 10, paddingRight: 10 }}
>
{items.map(({ icon, text, link }) => (
<Link href={link} key={text} passHref>
<UnstyledButton
sx={{
display: 'block',
width: '100%',
padding: theme.spacing.xs,
borderRadius: theme.radius.sm,
color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.black,
'&:hover': {
backgroundColor: theme.other.hover,
},
}}
>
<Group>
<ThemeIcon color='primary' variant='filled'>
{icon}
</ThemeIcon>
<AppBar
position='fixed'
elevation={0}
sx={{
width: { sm: `calc(100% - ${drawerWidth}px)` },
ml: { sm: `${drawerWidth}px` }
}}
>
<Toolbar>
<IconButton
color='inherit'
aria-label='open drawer'
edge='start'
onClick={() => setMobileOpen(true)}
sx={{ mr: 2, display: { sm: 'none' } }}
>
<MenuIcon />
</IconButton>
<Typography
variant='h5'
noWrap
component='div'
sx={{ display: { sm: 'none' } }}
>
Zipline
</Typography>
{user && (
<Box sx={{ marginLeft: 'auto' }}>
<Button
color='inherit'
aria-expanded={open ? 'true' : undefined}
onClick={handleClick}
<Text size='lg'>{text}</Text>
</Group>
</UnstyledButton>
</Link>
))}
{user.administrator && (
<Link href='/dashboard/users' passHref>
<UnstyledButton
sx={{
display: 'block',
width: '100%',
padding: theme.spacing.xs,
borderRadius: theme.radius.sm,
color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.black,
'&:hover': {
backgroundColor: theme.other.hover,
},
}}
>
<Group>
<ThemeIcon color='primary' variant='filled'>
<PersonIcon />
</ThemeIcon>
<Text size='lg'>Users</Text>
</Group>
</UnstyledButton>
</Link>
)}
</Navbar.Section>
</Navbar>
}
header={
<Header height={70} p='md'>
<div style={{ display: 'flex', alignItems: 'center', height: '100%' }}>
<MediaQuery largerThan='sm' styles={{ display: 'none' }}>
<Burger
opened={opened}
onClick={() => setOpened((o) => !o)}
size='sm'
color={theme.colors.gray[6]}
/>
</MediaQuery>
<Title sx={{ marginLeft: 12 }}>Zipline</Title>
<Box sx={{ marginLeft: 'auto', marginRight: 0 }}>
<Popover
position='top'
placement='end'
spacing={4}
opened={open}
onClose={() => setOpen(false)}
target={
<UnstyledButton
onClick={() => setOpen(!open)}
sx={{
display: 'block',
width: '100%',
padding: theme.spacing.xs,
borderRadius: theme.radius.sm,
color: theme.other.color,
'&:hover': {
backgroundColor: theme.other.hover,
},
}}
>
<Group>
<ThemeIcon color='primary' variant='filled'>
<GearIcon />
</ThemeIcon>
<Text>{user.username}</Text>
</Group>
</UnstyledButton>
}
>
<AccountIcon />
</Button>
<Menu
id='zipline-user-menu'
anchorEl={anchorEl}
open={open}
onClose={handleClose(null)}
MenuListProps={{
'aria-labelledby': 'basic-button',
}}
>
<MenuItem disableRipple>
<Typography variant='h5'>
<b>{user.username}</b>
</Typography>
</MenuItem>
<Divider />
<Link href='/dash/manage'>
<a style={{ color: 'white', textDecoration: 'none' }}>
<MenuItem onClick={handleClose(null)}>
<AccountIcon sx={{ mr: 2 }} /> Manage Account
</MenuItem>
</a>
</Link>
<MenuItem onClick={handleClose('copy')}>
<CopyIcon sx={{ mr: 2 }} /> Copy Token
</MenuItem>
<MenuItem onClick={handleClose('reset')}>
<ResetIcon sx={{ mr: 2 }} /> Reset Token
</MenuItem>
<Link href='/auth/logout'>
<a style={{ color: 'white', textDecoration: 'none' }}>
<MenuItem onClick={handleClose(null)}>
<LogoutIcon sx={{ mr: 2 }} /> Logout
</MenuItem>
</a>
</Link>
</Menu>
<Group direction='column' spacing={2}>
<Text sx={{
color: theme.colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6],
fontWeight: 500,
fontSize: theme.fontSizes.xs,
padding: `${theme.spacing.xs / 2}px ${theme.spacing.sm}px`,
cursor: 'default',
}}>User: {user.username}</Text>
<MenuItemLink icon={<GearIcon />} href='/dashboard/manage'>Manage Account</MenuItemLink>
<MenuItem icon={<CopyIcon />} onClick={() => {setOpen(false);openCopyToken();}}>Copy Token</MenuItem>
<MenuItem icon={<ResetIcon />} onClick={() => {setOpen(false);openResetToken();}} color='red'>Reset Token</MenuItem>
<MenuItemLink icon={<PinRightIcon />} href='/auth/logout' color='red'>Logout</MenuItemLink>
<Divider
variant='solid'
my={theme.spacing.xs / 2}
sx={theme => ({
width: '110%',
borderTopColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[2],
margin: `${theme.spacing.xs / 2}px -4px`,
})}
/>
<MenuItem icon={<Pencil1Icon />}>
<Select
size='xs'
data={Object.keys(themes).map(t => ({ value: t, label: friendlyThemeName[t] }))}
value={systemTheme}
onChange={handleUpdateTheme}
/>
</MenuItem>
</Group>
</Popover>
</Box>
)}
</Toolbar>
</AppBar>
<Box
component='nav'
sx={{
width: { sm: drawerWidth },
flexShrink: { sm: 0 }
}}
>
<Drawer
container={container}
variant='temporary'
onClose={() => setMobileOpen(false)}
open={mobileOpen}
elevation={0}
ModalProps={{
keepMounted: true
}}
sx={{
display: { xs: 'block', sm: 'none' },
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth }
}}
>
{drawer}
</Drawer>
<Drawer
variant='permanent'
sx={{
display: { xs: 'none', sm: 'block' },
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth }
}}
open
>
{drawer}
</Drawer>
</Box>
<Box component='main' sx={{ flexGrow: 1, p: 3, mt: 8 }}>
{user && noPaper ? children : (
<Paper elevation={0} sx={{ p: 2 }} variant='outlined'>
{children}
</Paper>
)}
</Box>
</Box>
</div>
</Header>
}
>
<Paper withBorder p='md' shadow='xs'>{children}</Paper>
</AppShell>
);
}
}

View File

@@ -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}

View 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>;
}

View File

@@ -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>
);
}

View File

@@ -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> */}
</>
);
}

View 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}
</>
);
}

View File

@@ -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}
</>
);
}

View File

@@ -1,223 +1,294 @@
import React, { useState } from 'react';
import { TextField, Button, Box, Typography, Select, MenuItem } from '@material-ui/core';
import React, { useEffect, useState } from 'react';
import { useFormik } from 'formik';
import * as yup from 'yup';
import useFetch from 'hooks/useFetch';
import Backdrop from 'components/Backdrop';
import Alert from 'components/Alert';
import Link from 'components/Link';
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
import { updateUser } from 'lib/redux/reducers/user';
import { useRouter } from 'next/router';
import { randomId, useForm, useInterval } from '@mantine/hooks';
import { Card, Tooltip, TextInput, Button, Text, Title, Group, ColorInput, MultiSelect, Space, Box, Table } from '@mantine/core';
import { DownloadIcon, Cross1Icon, TrashIcon } from '@modulz/radix-icons';
import { useNotifications } from '@mantine/notifications';
import { useModals } from '@mantine/modals';
const validationSchema = yup.object({
username: yup
.string()
.required('Username is required')
});
const themeValidationSchema = yup.object({
type: yup
.string()
.required('Type (dark, light) is required is required'),
primary: yup
.string()
.required('Primary color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
secondary: yup
.string()
.required('Secondary color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
error: yup
.string()
.required('Error color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
warning: yup
.string()
.required('Warning color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
info: yup
.string()
.required('Info color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
border: yup
.string()
.required('Border color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
mainBackground: yup
.string()
.required('Main Background is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
paperBackground: yup
.string()
.required('Paper Background is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
});
function TextInput({ id, label, formik, ...other }) {
function VarsTooltip({ children }) {
return (
<TextField
id={id}
name={id}
label={label}
value={formik.values[id]}
onChange={formik.handleChange}
error={formik.touched[id] && Boolean(formik.errors[id])}
helperText={formik.touched[id] && formik.errors[id]}
variant='standard'
fullWidth
sx={{ pb: 0.5 }}
{...other}
/>
<Tooltip position='top' placement='center' color='' label={
<>
<Text><b>{'{image.file}'}</b> - file name</Text>
<Text><b>{'{image.mimetype}'}</b> - mimetype</Text>
<Text><b>{'{image.id}'}</b> - id of the image</Text>
<Text><b>{'{user.name}'}</b> - your username</Text>
visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables
</>
}>
{children}
</Tooltip>
);
}
function ExportDataTooltip({ children }) {
return <Tooltip position='top' placement='center' color='' label='After clicking, if you have a lot of files the export can take a while to complete. A list of previous exports will be below to download.'>{children}</Tooltip>;
}
function ExportTable({ rows, columns }) {
return (
<Box sx={{ pt: 1 }} >
<Table highlightOnHover>
<thead>
<tr>
{columns.map(col => (
<th key={randomId()}>{col.name}</th>
))}
</tr>
</thead>
<tbody>
{rows.map(row => (
<tr key={randomId()}>
{columns.map(col => (
<td key={randomId()}>
{col.format ? col.format(row[col.id]) : row[col.id]}
</td>
))}
</tr>
))}
</tbody>
</Table>
</Box>
);
}
export default function Manage() {
const user = useStoreSelector(state => state.user);
const dispatch = useStoreDispatch();
const router = useRouter();
const notif = useNotifications();
const modals = useModals();
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [severity, setSeverity] = useState('success');
const [message, setMessage] = useState('Saved');
const [exports, setExports] = useState([]);
const [domains, setDomains] = useState(user.domains ?? []);
const formik = useFormik({
const genShareX = (withEmbed: boolean = false, withZws: boolean = false) => {
const config = {
Version: '13.2.1',
Name: 'Zipline',
DestinationType: 'ImageUploader, TextUploader',
RequestMethod: 'POST',
RequestURL: `${window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : '')}/api/upload`,
Headers: {
Authorization: user?.token,
...(withEmbed && { Embed: 'true' }),
...(withZws && { ZWS: 'true' }),
},
URL: '$json:files[0]$',
Body: 'MultipartFormData',
FileFormName: 'file',
};
const pseudoElement = document.createElement('a');
pseudoElement.setAttribute('href', 'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(config, null, '\t')));
pseudoElement.setAttribute('download', `zipline${withEmbed ? '_embed' : ''}${withZws ? '_zws' : ''}.sxcu`);
pseudoElement.style.display = 'none';
document.body.appendChild(pseudoElement);
pseudoElement.click();
pseudoElement.parentNode.removeChild(pseudoElement);
};
const form = useForm({
initialValues: {
username: user.username,
password: '',
embedTitle: user.embedTitle ?? '',
embedColor: user.embedColor
embedColor: user.embedColor,
embedSiteName: user.embedSiteName ?? '',
domains: user.domains ?? [],
},
validationSchema,
onSubmit: async values => {
const cleanUsername = values.username.trim();
const cleanPassword = values.password.trim();
const cleanEmbedTitle = values.embedTitle.trim();
const cleanEmbedColor = values.embedColor.trim();
if (cleanUsername === '') return formik.setFieldError('username', 'Username can\'t be nothing');
setLoading(true);
const data = {
username: cleanUsername,
password: cleanPassword === '' ? null : cleanPassword,
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor
};
const newUser = await useFetch('/api/user', 'PATCH', data);
if (newUser.error) {
setLoading(false);
setMessage('An error occured');
setSeverity('error');
setOpen(true);
} else {
dispatch(updateUser(newUser));
setLoading(false);
setMessage('Saved user');
setSeverity('success');
setOpen(true);
}
}
});
const customThemeFormik = useFormik({
initialValues: {
type: user.customTheme?.type || 'dark',
primary: user.customTheme?.primary || '',
secondary: user.customTheme?.secondary || '',
error: user.customTheme?.error || '',
warning: user.customTheme?.warning || '',
info: user.customTheme?.info || '',
border: user.customTheme?.border || '',
mainBackground: user.customTheme?.mainBackground || '',
paperBackground: user.customTheme?.paperBackground || '',
},
validationSchema: themeValidationSchema,
onSubmit: async values => {
setLoading(true);
const onSubmit = async values => {
const cleanUsername = values.username.trim();
const cleanPassword = values.password.trim();
const cleanEmbedTitle = values.embedTitle.trim();
const cleanEmbedColor = values.embedColor.trim();
const cleanEmbedSiteName = values.embedSiteName.trim();
const newUser = await useFetch('/api/user', 'PATCH', { customTheme: values });
console.log(newUser);
if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
if (newUser.error) {
setLoading(false);
setMessage('An error occured');
setSeverity('error');
setOpen(true);
} else {
dispatch(updateUser(newUser));
router.replace(router.pathname);
setLoading(false);
setMessage('Saved theme');
setSeverity('success');
setOpen(true);
const id = notif.showNotification({
title: 'Updating user...',
message: '',
loading: true,
autoClose: false,
});
const data = {
username: cleanUsername,
password: cleanPassword === '' ? null : cleanPassword,
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor,
embedSiteName: cleanEmbedSiteName === '' ? null : cleanEmbedSiteName,
domains,
};
const newUser = await useFetch('/api/user', 'PATCH', data);
if (newUser.error) {
if (newUser.invalidDomains) {
notif.updateNotification(id, {
message: <>
<Text mt='xs'>The following domains are invalid:</Text>
{newUser.invalidDomains.map(err => (
<>
<Text color='gray' key={randomId()}>{err.domain}: {err.reason}</Text>
<Space h='md' />
</>
))}
</>,
color: 'red',
icon: <Cross1Icon />,
});
}
notif.updateNotification(id, {
title: 'Couldn\'t save user',
message: newUser.error,
color: 'red',
icon: <Cross1Icon />,
});
} else {
dispatch(updateUser(newUser));
notif.updateNotification(id, {
title: 'Saved User',
message: '',
});
}
};
const exportData = async () => {
const res = await useFetch('/api/user/export', 'POST');
if (res.url) {
notif.showNotification({
title: 'Export started...',
loading: true,
message: 'If you have a lot of files, the export may take a while. The list of exports will be updated every 30s.',
});
}
};
const getExports = async () => {
const res = await useFetch('/api/user/export');
setExports(res.exports.map(s => ({
date: new Date(Number(s.split('_')[3].slice(0, -4))),
full: s,
})).sort((a, b) => a.date.getTime() - b.date.getTime()));
};
const handleDelete = async () => {
const res = await useFetch('/api/user/files', 'DELETE', {
all: true,
});
if (!res.count) {
notif.showNotification({
title: 'Couldn\'t delete files',
message: res.error,
color: 'red',
icon: <Cross1Icon />,
});
} else {
notif.showNotification({
title: 'Deleted files',
message: `${res.count} files deleted`,
color: 'green',
icon: <TrashIcon />,
});
}
};
const openDeleteModal = () => modals.openConfirmModal({
title: 'Are you sure you want to delete all of your images?',
closeOnConfirm: false,
centered: true,
overlayBlur: 3,
labels: { confirm: 'Yes', cancel: 'No' },
onConfirm: () => {
modals.openConfirmModal({
title: 'Are you really sure?',
centered: true,
overlayBlur: 3,
labels: { confirm: 'Yes', cancel: 'No' },
onConfirm: () => {
handleDelete();
modals.closeAll();
},
onCancel: () => {
modals.closeAll();
},
});
},
});
const interval = useInterval(() => getExports(), 30000);
useEffect(() => {
getExports();
interval.start();
}, []);
return (
<>
<Backdrop open={loading}/>
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
<Title>Manage User</Title>
<VarsTooltip>
<Text color='gray'>Want to use variables in embed text? Hover on this or visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables</Text>
</VarsTooltip>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput id='username' label='Username' {...form.getInputProps('username')} />
<TextInput id='password' label='Password' type='password' {...form.getInputProps('password')} />
<TextInput id='embedTitle' label='Embed Title' {...form.getInputProps('embedTitle')} />
<ColorInput id='embedColor' label='Embed Color' {...form.getInputProps('embedColor')} />
<TextInput id='embedSiteName' label='Embed Site Name' {...form.getInputProps('embedSiteName')} />
<MultiSelect
id='domains'
label='Domains'
data={domains}
placeholder='Leave blank if you dont want random domain selection.'
creatable
searchable
clearable
getCreateLabel={query => `Add ${query}`}
onCreate={query => setDomains((current) => [...current, query])}
{...form.getInputProps('domains')}
/>
<Typography variant='h4' pb={2}>Manage User</Typography>
<form onSubmit={formik.handleSubmit}>
<TextInput id='username' label='Username' formik={formik} />
<TextInput id='password' label='Password' formik={formik} type='password' />
<TextInput id='embedTitle' label='Embed Title' formik={formik} />
<TextInput id='embedColor' label='Embed Color' formik={formik} />
<Box
display='flex'
justifyContent='right'
alignItems='right'
pt={2}
>
<Group position='right' sx={{ paddingTop: 12 }}>
<Button
variant='contained'
type='submit'
>Save User</Button>
</Box>
</form>
<Typography variant='h4' py={2}>Manage Theme</Typography>
<form onSubmit={customThemeFormik.handleSubmit}>
<Select
id='type'
name='type'
label='Type'
value={customThemeFormik.values['type']}
onChange={customThemeFormik.handleChange}
error={customThemeFormik.touched['type'] && Boolean(customThemeFormik.errors['type'])}
variant='standard'
fullWidth
>
<MenuItem value='dark'>Dark Theme</MenuItem>
<MenuItem value='light'>Light Theme</MenuItem>
</Select>
<TextInput id='primary' label='Primary Color' formik={customThemeFormik} />
<TextInput id='secondary' label='Secondary Color' formik={customThemeFormik} />
<TextInput id='error' label='Error Color' formik={customThemeFormik} />
<TextInput id='warning' label='Warning Color' formik={customThemeFormik} />
<TextInput id='info' label='Info Color' formik={customThemeFormik} />
<TextInput id='border' label='Border Color' formik={customThemeFormik} />
<TextInput id='mainBackground' label='Main Background' formik={customThemeFormik} />
<TextInput id='paperBackground' label='Paper Background' formik={customThemeFormik} />
<Box
display='flex'
justifyContent='right'
alignItems='right'
pt={2}
>
<Button
variant='contained'
type='submit'
>Save Theme</Button>
</Box>
</Group>
</form>
<Title sx={{ paddingTop: 12 }}>Manage Data</Title>
<Text color='gray' sx={{ paddingBottom: 12 }}>Delete, or export your data into a zip file.</Text>
<Group>
<Button onClick={openDeleteModal} rightIcon={<TrashIcon />}>Delete All Data</Button>
<ExportDataTooltip><Button onClick={exportData} rightIcon={<DownloadIcon />}>Export Data</Button></ExportDataTooltip>
</Group>
<Card mt={22}>
<ExportTable
columns={[
{ id: 'name', name: 'Name' },
{ id: 'date', name: 'Date' },
]}
rows={exports ? exports.map((x, i) => ({
name: <Link href={'/api/user/export?name=' + x.full}>Export {i + 1}</Link>,
date: x.date.toLocaleString(),
})) : []} />
</Card>
<Title sx={{ paddingTop: 12, paddingBottom: 12 }}>ShareX Config</Title>
<Group>
<Button onClick={() => genShareX(false)} rightIcon={<DownloadIcon />}>ShareX Config</Button>
<Button onClick={() => genShareX(true)} rightIcon={<DownloadIcon />}>ShareX Config with Embed</Button>
<Button onClick={() => genShareX(false, true)} rightIcon={<DownloadIcon />}>ShareX Config with ZWS</Button>
</Group>
</>
);
}

View 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>
</>
);
}

View File

@@ -1,89 +1,176 @@
import React, { useState } from 'react';
import { Typography, Button, CardActionArea, Paper, Box } from '@material-ui/core';
import { Upload as UploadIcon } from '@material-ui/icons';
import Dropzone from 'react-dropzone';
import React, { useEffect, useState } from 'react';
import Backdrop from 'components/Backdrop';
import Alert from 'components/Alert';
import { useStoreSelector } from 'lib/redux/store';
import CenteredBox from 'components/CenteredBox';
import copy from 'copy-to-clipboard';
import Link from 'components/Link';
import { Image, Button, Group, Popover, Progress, Text, useMantineTheme, Tooltip, Stack, Table } from '@mantine/core';
import { ImageIcon, UploadIcon, CrossCircledIcon } from '@modulz/radix-icons';
import { Dropzone } from '@mantine/dropzone';
import { useNotifications } from '@mantine/notifications';
import { randomId, useClipboard } from '@mantine/hooks';
export default function Upload({ route }) {
function ImageUploadIcon({ status, ...props }) {
if (status.accepted) {
return <UploadIcon {...props} />;
}
if (status.rejected) {
return <CrossCircledIcon {...props} />;
}
return <ImageIcon {...props} />;
}
function getIconColor(status, theme) {
return status.accepted
? theme.colors[theme.primaryColor][6]
: status.rejected
? theme.colors.red[6]
: theme.colorScheme === 'dark'
? theme.colors.dark[0]
: theme.black;
}
function ImageDropzone({ file }: { file: File }) {
const theme = useMantineTheme();
return (
<Tooltip
position='top'
placement='center'
color={theme.colorScheme === 'dark' ? 'dark' : undefined}
styles={{
body: {
color: 'white',
},
}}
label={
<div style={{ display: 'flex', alignItems: 'center' }}>
<Image src={URL.createObjectURL(file)} alt={file.name} sx={{ maxWidth: '10vw', maxHeight: '100vh' }} mr='md' />
<Table>
<tbody>
<tr>
<td>Name</td>
<td>{file.name}</td>
</tr>
<tr>
<td>Type</td>
<td>{file.type}</td>
</tr>
<tr>
<td>Last Modified</td>
<td>{new Date(file.lastModified).toLocaleString()}</td>
</tr>
</tbody>
</Table>
</div>
}
>
<Text weight='bold'>{file.name}</Text>
</Tooltip>
);
}
export default function Upload() {
const theme = useMantineTheme();
const notif = useNotifications();
const clipboard = useClipboard();
const user = useStoreSelector(state => state.user);
const [file, setFile] = useState(null);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [severity, setSeverity] = useState('success');
const [message, setMessage] = useState('Saved');
const [files, setFiles] = useState([]);
const [progress, setProgress] = useState(0);
useEffect(() => {
window.addEventListener('paste', (e: ClipboardEvent) => {
const item = Array.from(e.clipboardData.items).find(x => /^image/.test(x.type));
const blob = item.getAsFile();
setFiles([...files, new File([blob], blob.name, { type: blob.type })]);
notif.showNotification({
title: 'Image imported from clipboard',
message: '',
});
});
});
const handleUpload = async () => {
setProgress(0);
const body = new FormData();
body.append('file', file);
for (let i = 0; i !== files.length; ++i) body.append('file', files[i]);
setLoading(true);
const res = await fetch('/api/upload', {
method: 'POST',
headers: {
'Authorization': user.token
},
body
const id = notif.showNotification({
title: 'Uploading Images...',
message: '',
loading: true,
autoClose: false,
});
const json = await res.json();
if (res.ok && json.error === undefined) {
setOpen(true);
setSeverity('success');
setMessage(`Copied to clipboard! ${json.url}`);
copy(json.url);
} else {
setOpen(true);
setSeverity('error');
setMessage('Could not upload file: ' + json.error);
}
setLoading(false);
const req = new XMLHttpRequest();
req.upload.addEventListener('progress', e => {
if (e.lengthComputable) {
setProgress(Math.round(e.loaded / e.total * 100));
}
});
req.addEventListener('load', e => {
// @ts-ignore not sure why it thinks response doesnt exist, but it does.
const json = JSON.parse(e.target.response);
if (json.error === undefined) {
notif.updateNotification(id, {
title: 'Upload Successful',
message: <>Copied first image to clipboard! <br />{json.files.map(x => (<Link key={x} href={x}>{x}<br /></Link>))}</>,
color: 'green',
icon: <UploadIcon />,
});
clipboard.copy(json.files[0]);
setFiles([]);
} else {
notif.updateNotification(id, {
title: 'Upload Failed',
message: json.error,
color: 'red',
icon: <CrossCircledIcon />,
});
}
setProgress(0);
}, false);
req.open('POST', '/api/upload');
req.setRequestHeader('Authorization', user.token);
req.send(body);
};
return (
<>
<Backdrop open={loading}/>
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
<Dropzone onDrop={(f) => setFiles([...files, ...f])}>
{status => (
<>
<Group position='center' spacing='xl' style={{ minHeight: 220, pointerEvents: 'none' }}>
<ImageUploadIcon
status={status}
style={{ width: 80, height: 80, color: getIconColor(status, theme) }}
/>
<Typography variant='h4' pb={2}>Upload file</Typography>
<Dropzone onDrop={acceptedFiles => setFile(acceptedFiles[0])}>
{({getRootProps, getInputProps}) => (
<CardActionArea>
<Paper
elevation={0}
variant='outlined'
sx={{
justifyContent: 'center',
alignItems: 'center',
display: 'block',
p: 5
}}
{...getRootProps()}
>
<input {...getInputProps()} />
<CenteredBox><UploadIcon sx={{ fontSize: 100 }} /></CenteredBox>
<CenteredBox><Typography variant='h5'>Drag an image or click to upload an image.</Typography></CenteredBox>
<CenteredBox><Typography variant='h6'>{file && file.name}</Typography></CenteredBox>
</Paper>
</CardActionArea>
<div>
<Text size='xl' inline>
Drag images here or click to select files
</Text>
</div>
</Group>
</>
)}
</Dropzone>
<Box
display='flex'
justifyContent='right'
alignItems='right'
pt={2}
>
<Button
variant='contained'
onClick={handleUpload}
>Upload</Button>
</Box>
<Group position='center' spacing='xl' mt={12}>
{files.map(file => (<ImageDropzone key={randomId()} file={file} />))}
</Group>
{progress !== 0 && <Progress sx={{ marginTop: 12 }} value={progress} />}
<Group position='right'>
<Button leftIcon={<UploadIcon />} mt={12} onClick={handleUpload}>Upload</Button>
</Group>
</>
);
}

View 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>
</>
);
}

View File

@@ -1,168 +1,141 @@
import React, { useState, useEffect } from 'react';
import {
Typography,
Card as MuiCard,
CardHeader,
Avatar,
IconButton,
Grid,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Switch,
FormControlLabel
} from '@material-ui/core';
import { Delete as DeleteIcon, Add as AddIcon } from '@material-ui/icons';
import { useStoreSelector } from 'lib/redux/store';
import Backdrop from 'components/Backdrop';
import Alert from 'components/Alert';
import useFetch from 'hooks/useFetch';
import { useRouter } from 'next/router';
import { useFormik } from 'formik';
import { useForm } from '@mantine/hooks';
import { Avatar, Modal, Title, TextInput, Group, Button, Card, ActionIcon, SimpleGrid, Switch, Skeleton, Checkbox } from '@mantine/core';
import { Cross1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
import { useNotifications } from '@mantine/notifications';
import { useModals } from '@mantine/modals';
function Card({ user, handleDelete }) {
return (
<MuiCard sx={{ minWidth: 270 }}>
<CardHeader
avatar={<Avatar>{user.username[0]}</Avatar>}
action={<IconButton onClick={() => handleDelete(user)}><DeleteIcon /></IconButton>}
title={<Typography variant='h6'>{user.username}</Typography>}
/>
</MuiCard>
);
}
function TextInput({ id, label, formik, ...other }) {
return (
<TextField
id={id}
name={id}
label={label}
value={formik.values[id]}
onChange={formik.handleChange}
error={formik.touched[id] && Boolean(formik.errors[id])}
helperText={formik.touched[id] && formik.errors[id]}
variant='standard'
fullWidth
sx={{ pb: 0.5 }}
{...other}
/>
);
}
function CreateUserDialog({ open, setOpen, updateUsers, setSeverity, setMessage, setLoading, setAlertOpen }) {
const formik = useFormik({
function CreateUserModal({ open, setOpen, updateUsers }) {
const form = useForm({
initialValues: {
username: '',
password: '',
administrator: false
administrator: false,
},
onSubmit: async (values) => {
const cleanUsername = values.username.trim();
const cleanPassword = values.password.trim();
if (cleanUsername === '') return formik.setFieldError('username', 'Username can\'t be nothing');
if (cleanPassword === '') return formik.setFieldError('password', 'Password can\'t be nothing');
const data = {
username: cleanUsername,
password: cleanPassword,
administrator: values.administrator
};
setOpen(false);
setLoading(true);
const res = await useFetch('/api/auth/create', 'POST', data);
if (res.error) {
setSeverity('error');
setMessage('Could\'nt create user: ' + res.error);
setAlertOpen(true);
} else {
setSeverity('success');
setMessage('Created user ' + res.username);
setAlertOpen(true);
updateUsers();
}
setLoading(false);
}
});
const notif = useNotifications();
const onSubmit = async (values) => {
const cleanUsername = values.username.trim();
const cleanPassword = values.password.trim();
if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
if (cleanPassword === '') return form.setFieldError('password', 'Password can\'t be nothing');
const data = {
username: cleanUsername,
password: cleanPassword,
administrator: values.administrator,
};
setOpen(false);
const res = await useFetch('/api/auth/create', 'POST', data);
if (res.error) {
notif.showNotification({
title: 'Failed to create user',
message: res.error,
icon: <TrashIcon />,
color: 'red',
});
} else {
notif.showNotification({
title: 'Created user: ' + cleanUsername,
message: '',
icon: <PlusIcon />,
color: 'green',
});
}
updateUsers();
};
return (
<div>
<Dialog
open={open}
onClose={() => setOpen(false)}
PaperProps={{
elevation: 1
}}
>
<DialogTitle>
Create User
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextInput id='username' label='Username' formik={formik} />
<TextInput id='password' label='Password' formik={formik} type='password' />
<FormControlLabel
id='administrator'
name='administrator'
value={formik.values.administrator}
onChange={formik.handleChange}
control={<Switch />}
label='Administrator?'
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
<Button type='submit' color='inherit'>
Create
</Button>
</DialogActions>
</form>
</Dialog>
</div>
<Modal
opened={open}
onClose={() => setOpen(false)}
title={<Title>Create User</Title>}
>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput id='username' label='Username' {...form.getInputProps('username')} />
<TextInput id='password' label='Password' type='password' {...form.getInputProps('password')} />
<Switch mt={12} id='administrator' label='Administrator' {...form.getInputProps('administrator')} />
<Group position='right' mt={22}>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button type='submit'>Create</Button>
</Group>
</form>
</Modal>
);
}
export default function Users() {
const user = useStoreSelector(state => state.user);
const router = useRouter();
const notif = useNotifications();
const modals = useModals();
const [users, setUsers] = useState([]);
const [open, setOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [severity, setSeverity] = useState('success');
const [message, setMessage] = useState('Saved');
const [loading, setLoading] = useState(true);
const handleDelete = async (user, delete_images) => {
const res = await useFetch('/api/users', 'DELETE', {
id: user.id,
delete_images,
});
if (res.error) {
notif.showNotification({
title: 'Failed to delete user',
message: res.error,
color: 'red',
icon: <Cross1Icon />,
});
} else {
notif.showNotification({
title: 'User deleted',
message: '',
color: 'green',
icon: <TrashIcon />,
});
updateUsers();
}
};
// 2-step modal for deleting user if they want to delete their images too.
const openDeleteModal = user => modals.openConfirmModal({
title: `Delete ${user.username}?`,
closeOnConfirm: false,
centered: true,
overlayBlur: 3,
labels: { confirm: 'Yes', cancel: 'No' },
onConfirm: () => {
modals.openConfirmModal({
title: `Delete ${user.username}'s images?`,
labels: { confirm: 'Yes', cancel: 'No' },
centered: true,
overlayBlur: 3,
onConfirm: () => {
handleDelete(user, true);
modals.closeAll();
},
onCancel: () => {
handleDelete(user, false);
modals.closeAll();
},
});
},
});
const updateUsers = async () => {
setLoading(true);
const us = await useFetch('/api/users');
if (!us.error) {
setUsers(us);
} else {
router.push('/dashboard');
};
setLoading(false);
};
const handleDelete = async (user) => {
const res = await useFetch('/api/users', 'DELETE', {
id: user.id
});
if (res.error) {
setMessage(`Could not delete ${user.username}`);
setSeverity('error');
setOpen(true);
} else {
setMessage(`Deleted user ${res.username}`);
setSeverity('success');
setOpen(true);
updateUsers();
}
};
useEffect(() => {
@@ -171,17 +144,38 @@ export default function Users() {
return (
<>
<Backdrop open={loading}/>
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
<CreateUserDialog open={createOpen} setOpen={setCreateOpen} setSeverity={setSeverity} setMessage={setMessage} setLoading={setLoading} updateUsers={updateUsers} setAlertOpen={setOpen} />
<Typography variant='h4' pb={2}>Users <IconButton onClick={() => setCreateOpen(true)}><AddIcon /></IconButton></Typography>
<Grid container spacing={2}>
{users.filter(x => x.username !== user.username).map((user, i) => (
<Grid item xs={12} sm={3} key={i}>
<Card user={user} handleDelete={handleDelete}/>
</Grid>
<CreateUserModal open={open} setOpen={setOpen} updateUsers={updateUsers} />
<Group>
<Title sx={{ marginBottom: 12 }}>Users</Title>
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon /></ActionIcon>
</Group>
<SimpleGrid
cols={3}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{users.length ? users.filter(x => x.username !== user.username).map(user => (
<Card key={user.id} sx={{ maxWidth: '100%' }}>
<Group position='apart'>
<Group position='left'>
<Avatar color={user.administrator ? 'primary' : 'dark'}>{user.username[0]}</Avatar>
<Title>{user.username}</Title>
</Group>
<Group position='right'>
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(user)}>
<TrashIcon />
</ActionIcon>
</Group>
</Group>
</Card>
)) : [1, 2, 3, 4].map(x => (
<div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
</div>
))}
</Grid>
</SimpleGrid>
</>
);
}

16
src/lib/clientUtils.ts Normal file
View 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());
}

View File

@@ -1,6 +1,6 @@
import type { Config } from './types';
import readConfig from './readConfig';
import readConfig from './config/readConfig';
import validateConfig from './config/validateConfig';
if (!global.config) global.config = readConfig() as Config;
if (!global.config) global.config = validateConfig(readConfig());
export default global.config;

98
src/lib/config/Config.ts Normal file
View File

@@ -0,0 +1,98 @@
export interface ConfigCore {
// Whether to return http or https links
secure: boolean;
// Used for signing of cookies and other stuff
secret: string;
// The host Zipline will run on
host: string;
// The port Zipline will run on
port: number;
// The PostgreSQL database url
database_url: string
// Whether or not to log stuff
logger: boolean;
// The interval to store stats
stats_interval: number;
}
export interface ConfigDatasource {
// The type of datasource
type: 'local' | 's3';
// The local datasource, the default
local: ConfigLocalDatasource;
// The s3 datasource
s3?: ConfigS3Datasource;
}
export interface ConfigLocalDatasource {
// The directory to store files in
directory: string;
}
export interface ConfigS3Datasource {
// The access key id for the s3 bucket
access_key_id: string;
// The secret access key for the s3 bucket
secret_access_key: string;
// Not required, but if using a non-aws S3 service you can specify the endpoint
endpoint?: string;
// The S3 bucket to store files in
bucket: string;
// If true Zipline will attempt to connect to the bucket via the url "https://s3.amazonaws.com/{bucket}/stuff"
// If false Zipline will attempt to connect to the bucket via the url "http://{bucket}.s3.amazonaws.com/stuff"
force_s3_path: boolean;
}
export interface ConfigUploader {
// The route uploads will be served on
route: string;
// Length of random chars to generate for file names
length: number;
// Admin file upload limit
admin_limit: number;
// User file upload limit
user_limit: number;
// Disabled extensions to block from uploading
disabled_extensions: string[];
}
export interface ConfigUrls {
// The route urls will be served on
route: string;
// Length of random chars to generate for urls
length: number;
}
// Ratelimiting for users/admins, setting them to 0 disables ratelimiting
export interface ConfigRatelimit {
// Ratelimit for users
user: number;
// Ratelimit for admins
admin: number;
}
export interface Config {
core: ConfigCore;
uploader: ConfigUploader;
urls: ConfigUrls;
ratelimit: ConfigRatelimit;
datasource: ConfigDatasource;
}

View File

@@ -0,0 +1,127 @@
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import parse from '@iarna/toml/parse-string';
import Logger from '../logger';
import { Config } from './Config';
const e = (val, type, fn) => ({ val, type, fn });
const envValues = [
e('SECURE', 'boolean', (c, v) => c.core.secure = v),
e('SECRET', 'string', (c, v) => c.core.secret = v),
e('HOST', 'string', (c, v) => c.core.host = v),
e('PORT', 'number', (c, v) => c.core.port = v),
e('DATABASE_URL', 'string', (c, v) => c.core.database_url = v),
e('LOGGER', 'boolean', (c, v) => c.core.logger = v ?? true),
e('STATS_INTERVAL', 'number', (c, v) => c.core.stats_interval = v),
e('DATASOURCE_TYPE', 'string', (c, v) => c.datasource.type = v),
e('DATASOURCE_LOCAL_DIRECTORY', 'string', (c, v) => c.datasource.local.directory = v),
e('DATASOURCE_S3_ACCESS_KEY_ID', 'string', (c, v) => c.datasource.s3.access_key_id = v),
e('DATASOURCE_S3_SECRET_ACCESS_KEY', 'string', (c, v) => c.datasource.s3.secret_access_key = v),
e('DATASOURCE_S3_ENDPOINT', 'string', (c, v) => c.datasource.s3.endpoint = v ?? null),
e('DATASOURCE_S3_FORCE_S3_PATH', 'boolean', (c, v) => c.datasource.s3.force_s3_path = v ?? false),
e('DATASOURCE_S3_BUCKET', 'string', (c, v) => c.datasource.s3.bucket = v),
e('UPLOADER_ROUTE', 'string', (c, v) => c.uploader.route = v),
e('UPLOADER_LENGTH', 'number', (c, v) => c.uploader.length = v),
e('UPLOADER_ADMIN_LIMIT', 'number', (c, v) => c.uploader.admin_limit = v),
e('UPLOADER_USER_LIMIT', 'number', (c, v) => c.uploader.user_limit = v),
e('UPLOADER_DISABLED_EXTS', 'array', (c, v) => v ? c.uploader.disabled_extensions = v : c.uploader.disabled_extensions = []),
e('URLS_ROUTE', 'string', (c, v) => c.urls.route = v),
e('URLS_LENGTH', 'number', (c, v) => c.urls.length = v),
e('RATELIMIT_USER', 'number', (c, v) => c.ratelimit.user = v ?? 0),
e('RATELIMIT_ADMIN', 'number', (c, v) => c.ratelimit.user = v ?? 0),
];
export default function readConfig(): Config {
if (!existsSync(join(process.cwd(), 'config.toml'))) {
if (!process.env.ZIPLINE_DOCKER_BUILD) Logger.get('config').info('reading environment');
return tryReadEnv();
} else {
if (process.env.ZIPLINE_DOCKER_BUILD) return;
Logger.get('config').info('reading config file');
const str = readFileSync(join(process.cwd(), 'config.toml'), 'utf8');
const parsed = parse(str);
return parsed;
}
};
function tryReadEnv(): Config {
const config = {
core: {
secure: undefined,
secret: undefined,
host: undefined,
port: undefined,
database_url: undefined,
logger: undefined,
stats_interval: undefined,
},
datasource: {
type: undefined,
local: {
directory: undefined,
},
s3: {
access_key_id: undefined,
secret_access_key: undefined,
endpoint: undefined,
bucket: undefined,
force_s3_path: undefined,
},
},
uploader: {
route: undefined,
length: undefined,
admin_limit: undefined,
user_limit: undefined,
disabled_extensions: undefined,
},
urls: {
route: undefined,
length: undefined,
},
ratelimit: {
user: undefined,
admin: undefined,
},
};
for (let i = 0, L = envValues.length; i !== L; ++i) {
const envValue = envValues[i];
let value: any = process.env[envValue.val];
if (!value) {
envValues[i].fn(config, undefined);
} else {
envValues[i].fn(config, value);
if (envValue.type === 'number') value = parseToNumber(value);
else if (envValue.type === 'boolean') value = parseToBoolean(value);
else if (envValue.type === 'array') value = parseToArray(value);
envValues[i].fn(config, value);
}
}
return config;
}
function parseToNumber(value) {
// infer that it is a string since env values are only strings
const number = Number(value);
if (isNaN(number)) return undefined;
return number;
}
function parseToBoolean(value) {
// infer that it is a string since env values are only strings
if (!value || value === 'false') return false;
else return true;
}
function parseToArray(value) {
return value.split(',');
}

View File

@@ -0,0 +1,62 @@
import { Config } from 'lib/config/Config';
import { object, bool, string, number, boolean, array } from 'yup';
const validator = object({
core: object({
secure: bool().default(false),
secret: string().min(8).required(),
host: string().default('0.0.0.0'),
port: number().default(3000),
database_url: string().required(),
logger: boolean().default(false),
stats_interval: number().default(1800),
}).required(),
datasource: object({
type: string().default('local'),
local: object({
directory: string().default('./uploads'),
}),
s3: object({
access_key_id: string(),
secret_access_key: string(),
endpoint: string().notRequired().nullable(),
bucket: string(),
force_s3_path: boolean().default(false),
}).notRequired(),
}).required(),
uploader: object({
route: string().default('/u'),
embed_route: string().default('/a'),
length: number().default(6),
admin_limit: number().default(104900000),
user_limit: number().default(104900000),
disabled_extensions: array().default([]),
}).required(),
urls: object({
route: string().default('/go'),
length: number().default(6),
}).required(),
ratelimit: object({
user: number().default(0),
admin: number().default(0),
}),
});
export default function validate(config): Config {
try {
const validated = validator.validateSync(config, { abortEarly: false });
if (validated.datasource.type === 's3') {
const errors = [];
if (!validated.datasource.s3.access_key_id) errors.push('datasource.s3.access_key_id is a required field');
if (!validated.datasource.s3.secret_access_key) errors.push('datasource.s3.secret_access_key is a required field');
if (!validated.datasource.s3.bucket) errors.push('datasource.s3.bucket is a required field');
if (errors.length) throw { errors };
}
return validated as unknown as Config;
} catch (e) {
if (process.env.ZIPLINE_DOCKER_BUILD) return null;
throw `${e.errors.length} errors occured\n${e.errors.map(x => '\t' + x).join('\n')}`;
}
};

20
src/lib/datasource.ts Normal file
View File

@@ -0,0 +1,20 @@
import config from './config';
import { S3, Local } from './datasources';
import Logger from './logger';
if (!global.datasource) {
switch (config.datasource.type) {
case 's3':
global.datasource = new S3(config.datasource.s3);
Logger.get('datasource').info(`Using S3(${config.datasource.s3.bucket}) datasource`);
break;
case 'local':
global.datasource = new Local(config.datasource.local.directory);
Logger.get('datasource').info(`Using local(${config.datasource.local.directory}) datasource`);
break;
default:
throw new Error('Invalid datasource type');
}
}
export default global.datasource;

View File

@@ -0,0 +1,10 @@
import { Readable } from 'stream';
export abstract class Datasource {
public name: string;
public abstract save(file: string, data: Buffer): Promise<void>;
public abstract delete(file: string): Promise<void>;
public abstract get(file: string): Readable;
public abstract size(): Promise<number>;
}

View File

@@ -0,0 +1,43 @@
import { createReadStream, existsSync, ReadStream } from 'fs';
import { readdir, rm, stat, writeFile } from 'fs/promises';
import { join } from 'path';
import { Datasource } from './';
export class Local extends Datasource {
public name: string = 'local';
public constructor(public path: string) {
super();
}
public async save(file: string, data: Buffer): Promise<void> {
await writeFile(join(process.cwd(), this.path, file), data);
}
public async delete(file: string): Promise<void> {
await rm(join(process.cwd(), this.path, file));
}
public get(file: string): ReadStream {
const full = join(process.cwd(), this.path, file);
if (!existsSync(full)) return null;
try {
return createReadStream(full);
} catch (e) {
return null;
}
}
public async size(): Promise<number> {
const files = await readdir(this.path);
let size = 0;
for (let i = 0, L = files.length; i !== L; ++i) {
const sta = await stat(join(this.path, files[i]));
size += sta.size;
}
return size;
}
}

75
src/lib/datasources/S3.ts Normal file
View File

@@ -0,0 +1,75 @@
import { Datasource } from './';
import AWS from 'aws-sdk';
import { Readable } from 'stream';
import { ConfigS3Datasource } from 'lib/config/Config';
export class S3 extends Datasource {
public name: string = 'S3';
public s3: AWS.S3;
public constructor(
public config: ConfigS3Datasource,
) {
super();
this.s3 = new AWS.S3({
accessKeyId: config.access_key_id,
endpoint: config.endpoint || null,
s3ForcePathStyle: config.force_s3_path,
secretAccessKey: config.secret_access_key,
});
}
public async save(file: string, data: Buffer): Promise<void> {
return new Promise((resolve, reject) => {
this.s3.upload({
Bucket: this.config.bucket,
Key: file,
Body: data,
}, err => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
public async delete(file: string): Promise<void> {
return new Promise((resolve, reject) => {
this.s3.deleteObject({
Bucket: this.config.bucket,
Key: file,
}, err => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
public get(file: string): Readable {
// Unfortunately, aws-sdk is bad and the stream still loads everything into memory.
return this.s3.getObject({
Bucket: this.config.bucket,
Key: file,
}).createReadStream();
}
public async size(): Promise<number> {
return new Promise((resolve, reject) => {
this.s3.listObjects({
Bucket: this.config.bucket,
}, (err, data) => {
if (err) {
reject(err);
} else {
const size = data.Contents.reduce((acc, cur) => acc + cur.Size, 0);
resolve(size);
}
});
});
}
}

View File

@@ -0,0 +1,3 @@
export { Datasource } from './Datasource';
export { Local } from './Local';
export { S3 } from './S3';

View File

@@ -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();

View File

@@ -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();
}, []);

View File

@@ -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
View File

@@ -0,0 +1,45 @@
import { format } from 'fecha';
import { blueBright, red, cyan } from 'colorette';
export enum LoggerLevel {
ERROR,
INFO,
}
export default class Logger {
public name: string;
static get(clas: any) {
if (typeof clas !== 'function') if (typeof clas !== 'string') throw new Error('not string/function');
const name = clas.name ?? clas;
return new Logger(name);
}
constructor(name: string) {
this.name = name;
}
info(...args: any[]) {
console.log(this.formatMessage(LoggerLevel.INFO, this.name, args.join(' ')));
}
error(...args: any[]) {
console.log(this.formatMessage(LoggerLevel.ERROR, this.name, args.map(error => error.stack ?? error).join(' ')));
}
formatMessage(level: LoggerLevel, name: string, message: string) {
const time = format(new Date(), 'YYYY-MM-DD hh:mm:ss,SSS A');
return `${time} ${this.formatLevel(level)} [${blueBright(name)}] ${message}`;
}
formatLevel(level: LoggerLevel) {
switch (level) {
case LoggerLevel.INFO:
return cyan('INFO ');
case LoggerLevel.ERROR:
return red('ERROR');
}
}
};

View File

@@ -1,6 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import type { CookieSerializeOptions } from 'cookie';
import type { Image, Theme, User } from '@prisma/client';
import { serialize } from 'cookie';
import { sign64, unsign64 } from '../util';
@@ -12,7 +11,7 @@ export interface NextApiFile {
originalname: string;
encoding: string;
mimetype: string;
buffer: string;
buffer: Buffer;
size: number;
}
@@ -23,52 +22,62 @@ export type NextApiReq = NextApiRequest & {
embedTitle: string;
embedColor: string;
systemTheme: string;
customTheme?: Theme;
administrator: boolean;
id: number;
images: Image[];
password: string;
domains: string[];
} | null | void>;
getCookie: (name: string) => string | null;
cleanCookie: (name: string) => void;
file?: NextApiFile;
files?: NextApiFile[];
}
export type NextApiRes = NextApiResponse & {
error: (message: string) => void;
forbid: (message: string) => void;
forbid: (message: string, extra?: any) => void;
bad: (message: string) => void;
json: (json: any) => void;
ratelimited: (remaining: number) => void;
setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void;
}
export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse) => unknown) => (req: NextApiReq, res: NextApiRes) => {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Content-Allow-Methods', 'GET,HEAD,POST,OPTIONS');
res.setHeader('Access-Control-Max-Age', '86400');
res.error = (message: string) => {
res.setHeader('Content-Type', 'application/json');
res.json({
error: message
error: message,
});
};
res.forbid = (message: string) => {
res.forbid = (message: string, extra: any = {}) => {
res.setHeader('Content-Type', 'application/json');
res.status(403);
res.json({
error: '403: ' + message
error: '403: ' + message,
...extra,
});
};
res.bad = (message: string) => {
res.setHeader('Content-Type', 'application/json');
res.status(401);
res.json({
error: '403: ' + message
error: '403: ' + message,
});
};
res.ratelimited = (remaining: number) => {
res.status(429);
res.setHeader('X-Ratelimit-Remaining', Math.floor(remaining / 1000));
res.json({
error: '429: ratelimited',
});
};
res.json = (json: any) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(json));
};
@@ -83,9 +92,10 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
res.setHeader('Set-Cookie', serialize(name, '', {
path: '/',
expires: new Date(1),
maxAge: undefined
maxAge: undefined,
}));
};
req.user = async () => {
try {
const userId = req.getCookie('user');
@@ -93,20 +103,19 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
const user = await prisma.user.findFirst({
where: {
id: Number(userId)
id: Number(userId),
},
select: {
administrator: true,
embedColor: true,
embedTitle: true,
id: true,
images: true,
password: true,
systemTheme: true,
customTheme: true,
token: true,
username: true
}
username: true,
domains: true,
},
});
if (!user) return null;
@@ -132,11 +141,11 @@ export const setCookie = (
) => {
if ('maxAge' in options) {
options.expires = new Date(Date.now() + options.maxAge);
options.expires = new Date(Date.now() + options.maxAge * 1000);
options.maxAge /= 1000;
}
const signed = sign64(String(value), config.core.secret);
res.setHeader('Set-Cookie', serialize(name, signed, options));
};
};

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -1,4 +1,3 @@
import { Theme } from '@prisma/client';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface User {
@@ -6,8 +5,9 @@ export interface User {
token: string;
embedTitle: string;
embedColor: string;
embedSiteName: string;
systemTheme: string;
customTheme?: Theme;
domains: string[];
}
const initialState: User = null;

View File

@@ -1,54 +1,13 @@
// https://github.com/mikecao/umami/blob/master/redux/store.js
import { useMemo } from 'react';
import { Action, CombinedState, configureStore, EnhancedStore } from '@reduxjs/toolkit';
import thunk, { ThunkAction } from 'redux-thunk';
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './reducers';
import { User } from './reducers/user';
import { useDispatch, TypedUseSelectorHook, useSelector } from 'react-redux';
let store: EnhancedStore<CombinedState<{
user: User;
}>>;
export function getStore(preloadedState) {
return configureStore({
reducer: rootReducer,
middleware: [thunk],
preloadedState,
});
}
export const initializeStore = preloadedState => {
let _store = store ?? getStore(preloadedState);
if (preloadedState && store) {
_store = getStore({
...store.getState(),
...preloadedState,
});
store = undefined;
}
if (typeof window === 'undefined') return _store;
if (!store) store = _store;
return _store;
};
export function useStore(initialState?: User) {
return useMemo(() => initializeStore(initialState), [initialState]);
}
export const store = configureStore({
reducer: rootReducer,
});
export type AppState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
AppState,
unknown,
Action<User>
>
export const useStoreDispatch = () => useDispatch<AppDispatch>();
export const useStoreSelector: TypedUseSelectorHook<AppState> = useSelector;

View File

@@ -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',
],
},
});

View File

@@ -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',
],
},
});

View File

@@ -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',
],
},
});

View File

@@ -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',
],
},
});

View File

@@ -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
View File

@@ -0,0 +1,38 @@
// https://github.com/AlphaNecron/
// https://github.com/dracula/dracula-theme
import createTheme from '.';
export default createTheme({
colorScheme: 'dark',
primaryColor: 'violet',
other: {
AppShell_backgroundColor: '#282A36',
hover: '#4e5062',
},
colors: {
dark: [
'#FFFFFF',
'#CED0D4',
'#E8E8EB',
'#D1D1D6',
'#BABAC2',
'#A2A3AD',
'#4e5062',
'#44475A',
'#5C5E6F',
'#44475A',
],
violet: [
'#FFFFFF',
'#F7F2FF',
'#EFE4FE',
'#EBDEFE',
'#E7D7FD',
'#DEC9FC',
'#D6BCFC',
'#CEAEFB',
'#C6A1FA',
'#BD93F9',
],
},
});

Some files were not shown because too many files have changed in this diff Show More