Compare commits

...

122 Commits

Author SHA1 Message Date
TacticalCoderJay b9a7da0cea fix: breaking changes in migrating mantine v6 2023-03-04 16:07:43 -08:00
diced 2c24cafab8 feat: use pininput for 2fa 2023-03-04 14:40:54 -08:00
diced df013a52d1 fix: user button 2023-03-04 12:43:10 -08:00
diced 61db4eaddb remove: useless size modifier 2023-03-04 12:42:57 -08:00
diced fa632987dd feat: use api option 2023-03-04 12:42:41 -08:00
dicedtomato 66dc22e860 feat: initial move to mantine v6 2023-03-04 20:12:26 +00:00
dicedtomato 9b22ad2ab6 refactor: remove old File.tsx 2023-03-04 05:10:07 +00:00
dicedtomato 986858345e fix: #311 2023-03-04 04:52:00 +00:00
dicedtomato 912e439645 feat: file size (#308)
* feat: baseline support for file sizes

* feat: script to add file sizes
2023-03-03 20:40:28 -08:00
Jayvin Hernandez 8e44b71614 fix: fix (#310)
* Muted audio by default!

* Code renderin'

* not but still decently standard files being viewable

* reserved routes

* Update validateConfig.ts

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-03-03 20:19:19 -08:00
dicedtomato 11bca28ef5 fix: show warning when password protect 2023-03-04 04:15:24 +00:00
dicedtomato 4ef0c6021a feat(v3.7.0-rc4): version 2023-02-27 03:46:50 +00:00
dicedtomato 4fbbd58ae9 chore: update deps 2023-02-27 03:46:27 +00:00
dicedtomato 81dea6cf90 feat: logger improvements
- Timestamp is gray
- removed colorette dependency
- introduction of LOGGER_FILTERS
2023-02-27 01:54:37 +00:00
dicedtomato 9b57fb280b feat: clear zero byte files script 2023-02-27 01:52:39 +00:00
dicedtomato e804d0b31e feat: more functionality within files table 2023-02-27 01:52:22 +00:00
dicedtomato 76845fc7e4 fix: revamp mobile ui 2023-02-26 20:45:20 +00:00
dicedtomato decd7f7918 fix: revamp uploaded file modal 2023-02-26 20:20:36 +00:00
Jayvin Hernandez 8c5ff4f230 fix: clipboard & 2fa improvements
A workaround that shows the content that would have been copied if `navigator.clipboard` is unavailable for whatever reason.

2FA input autofocuses & submits on enter.
2023-02-26 11:33:57 -08:00
dicedtomato 0848702f65 fix: title for folders 2023-02-26 04:57:44 +00:00
Jayvin Hernandez 5379374135 feat: seperate discord webhooks (shorten/upload) (#260) 2023-02-25 20:47:14 -08:00
Jayvin Hernandez b7772128d7 fix: default public folder (docker) 2023-02-25 20:36:05 -08:00
Jayvin Hernandez 95a1c7f92c feat: clearing orphaned files (#303) 2023-02-25 20:35:08 -08:00
Jayvin Hernandez 2d69cd580a fix: show files per user (#299) 2023-02-23 21:33:12 -08:00
dicedtomato 34552926d1 fix: #296 2023-02-24 00:18:03 +00:00
Jayvin Hernandez 739f584921 fix: spaces and route fixes (#294)
* fix: spaces and route fixes

* fix: shorten url response

* feat: better version checking

* fix: use special characters should work

If it doesn't, better call saul

* save that extra byte

* fix: returning protocol again in domain

unrelated to this pr but whatever

* fix: above ^

* Rename shorten.tsv to shorten.ts

---------

Co-authored-by: diced <pranaco2@gmail.com>
Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-02-23 16:16:11 -08:00
Jayvin Hernandez 04d8b6421a feat: devcontainers for codespaces, etc.
* experiment with devcontainer.json

* introduce a docker-compose for devcontainer

* Devcontainers!

* version pop

* Port labeling and a complimentary env variable

* see it to believe it

* Update .devcontainer/devcontainer.json

* Update .devcontainer/devcontainer.json

* Update .devcontainer/docker-compose.yml

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-02-23 16:00:59 -08:00
diced fdcd1f3d28 fix: dates #278 2023-02-23 14:42:39 -08:00
diced fc02dc02e8 feat: public folders 2023-02-23 14:37:22 -08:00
diced 6955d83b0c feat: better version checking 2023-02-20 23:17:04 -08:00
diced 1b3d3a867b fix: random domains 2023-02-18 14:08:49 -08:00
diced 83718d7b31 feat: override domain header 2023-02-18 11:19:50 -08:00
IThundxr e80627a3c3 fix: entrypoint executable (#289) 2023-02-18 10:02:40 -08:00
diced e1003d4bb6 fix: url encode password query 2023-02-17 19:45:04 -08:00
diced 2ef4a52be0 fix: set password to actual text value 2023-02-17 19:44:51 -08:00
IThundxr 93a63d3714 feat: use ENTRYPOINT in docker (#286)
* :3

* Update Dockerfile

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>

* Update Dockerfile

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>

* Update Dockerfile

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>

* test

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-02-17 19:09:50 -08:00
Jayvin Hernandez a8d9d98cf2 fix: return null for no string in parser (#285) 2023-02-16 16:47:07 -08:00
Jayvin Hernandez d70ddd1f53 feat: search+create for folder select (#283)
* feat?: Search for the folder to add.
Also you can create a folder right from the file, rather than being redirected.

* woops wrong import
2023-02-13 19:37:47 -08:00
Jayvin Hernandez 283c7c5a26 fix: use name instead of file (#281)
Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-02-10 22:47:06 -08:00
diced fb5f50d5bd feat(v3.7.0-rc3): folders for files 2023-02-10 22:32:57 -08:00
diced 06e84b41aa fix: /app -> /zipline 2023-01-28 10:28:29 -08:00
Jayvin Hernandez e3f262322a fix: root url for upload and shorten (#255)
Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-01-26 20:06:30 -08:00
diced 70c2fa8ef4 fix: optimize docker image 2023-01-26 15:58:22 -08:00
diced 9f534e18c8 fix: allow more variables on view 2023-01-26 15:58:06 -08:00
Jayvin Hernandez 55bd72aef8 fix: add a "skip" for fresh db's (#274)
* fix: add a "skip" for fresh db's

* fix: trimming

* fix: elevate logging!
2023-01-26 12:46:10 -08:00
IceToast c1a23faf1f fix: #277 #272
* fix: 🐛 Add Menu component as parent

* refactor: popover -> menu

Co-authored-by: IceToast <>
Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-01-25 17:43:16 -08:00
dicedtomato 3588c297f8 fix: sharex config 2023-01-19 04:44:37 +00:00
diced 04d03cbc8f fix: ensureDatabaseExists args 2023-01-18 09:25:54 -08:00
diced 4e27efb6a1 fix: sharex DestinationType 2023-01-18 09:05:12 -08:00
diced 59b3e5bb24 feat(v3.7.0-rc2): version 2023-01-15 21:09:51 -08:00
diced d8eee3d81a fix: no name on dashboard 2023-01-15 21:07:11 -08:00
diced c8926682b2 fix: type error 2023-01-15 16:58:26 -08:00
diced 9117a9d779 fix: ability to gen with original-name 2023-01-15 14:05:37 -08:00
diced 4ea1775f2c feat: keep original name #247 2023-01-15 13:57:28 -08:00
diced a8020ecebe refactor: many columns/tables in prisma 2023-01-15 13:39:07 -08:00
Jayvin Hernandez 2ace076fce fix: cors for files (#257)
Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-01-15 11:26:30 -08:00
diced 45e897d475 feat(3.7.0-rc1): version 2023-01-14 15:27:21 -08:00
diced 98676f0573 fix: docker stuff 2023-01-14 15:25:36 -08:00
diced c966ab9a52 fix: embeds not showing up 2023-01-14 13:50:56 -08:00
diced ebaf11ad10 fix: group icons vertically 2023-01-14 13:27:23 -08:00
diced 19c7ba03c6 fix: clean 2023-01-14 13:20:04 -08:00
diced 894b5c5c6c feat: overhaul image upload 2023-01-14 12:04:30 -08:00
diced 516e93cee2 feat: ability to insert tabs on Tab keypress 2023-01-14 11:27:59 -08:00
diced cc0ffc6e60 fix: react hooks error 2023-01-14 11:21:56 -08:00
diced a97ace6e73 fix: sxcu name 2023-01-14 10:30:24 -08:00
diced 6d49463dad feat: ability to generate url shorten config 2023-01-14 10:25:54 -08:00
diced 81e6e4e5f2 fix: better icons on file vie2 2023-01-13 19:40:15 -08:00
diced 2adb355183 feat: download query on /r/ 2023-01-13 17:28:38 -08:00
diced 5e6c53432b refactor: chart.js -> recharts 2023-01-13 17:20:40 -08:00
diced 873f77bc43 fix: overrides for uploading 2023-01-12 20:17:25 -08:00
diced 9bf098a93a fix: overwrite tmp ss flameshot 2023-01-11 21:44:24 -08:00
diced 388713a3c6 feat: new embed method 2023-01-11 21:33:01 -08:00
diced e94dd58542 fix: do not mutate res #266 2023-01-10 21:49:59 -08:00
diced d985a1c588 fix: remove esbuild 2023-01-06 15:08:18 -08:00
diced dbac6e8918 fix: urls handle empty strings 2023-01-06 15:06:34 -08:00
diced a481c0ee5e fix: #264 2023-01-06 15:04:43 -08:00
diced eef6fdaeb3 feat: tsup, small fixes 2023-01-06 14:45:48 -08:00
Jayvin Hernandez b8b1a5bba6 fix: catch hopefully the most of the edge cases (#251)
* fix: catch hopefully the most of the edge cases

* fix: invite only, fools
2022-12-29 20:39:32 -08:00
Jayvin Hernandez f06f52fce7 Fix root url & uploader stuff (#254)
* fix: uploader route as root won't be broken

* fix: fix broken url route for when on root
2022-12-18 17:29:50 -08:00
Jayvin Hernandez 4a332bb77b fix: forgor (#253) 2022-12-18 16:07:38 -08:00
Jayvin Hernandez eb1b202566 fix: catch null at other places (#252) 2022-12-18 15:05:54 -08:00
diced 658f3a1a09 fix: add a forgotten ? to schema 2022-12-17 14:12:44 -08:00
Jayvin Hernandez 55eba480ac hotfix: fallback oauth find (#250) 2022-12-17 13:18:15 -08:00
Jayvin Hernandez bbeea5b0ec hotfix: make oauthid optional (#249) 2022-12-17 09:37:29 -08:00
diced ad454a94ef fix: remove optional 2022-12-17 09:33:07 -08:00
diced 268215ff5f fix: oauthId optional 2022-12-17 09:06:00 -08:00
diced 4e70daa4d8 feat(v3.6.4): version 2022-12-15 20:13:45 -08:00
diced bb28f49cf5 feat: default avatar stuffs 2022-12-15 20:09:02 -08:00
diced d85211a145 feat: better nav bar stuff 2022-12-15 20:09:02 -08:00
diced a7291d374d fix: favorite pagination num 2022-12-15 20:09:02 -08:00
Jayvin Hernandez 5c9b558ac2 chore: update the readme (#246)
feature requests are now discussion threads instead of issues

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2022-12-15 18:09:36 -08:00
diced 36ede22d45 fix: readme sheilds.io images 2022-12-15 18:02:10 -08:00
diced 6528ec4056 fix: add default exit transition to modals 2022-12-14 22:46:41 -08:00
diced 56ee494c7d fix: add wayland instructions in flameshot builder 2022-12-14 22:45:10 -08:00
diced b21995a0b9 fix: cleanup old methods from /api/user/files 2022-12-14 22:18:50 -08:00
diced 3c00575ecd feat: new system for paged files 2022-12-13 23:32:57 -08:00
diced 27ccbcb54a chore: pg latest -> pg 15 2022-12-13 22:26:50 -08:00
diced fecbf394c1 fix: remove comments 2022-12-11 15:37:59 -08:00
diced 91341e2d21 feat: new variables parser 2022-12-11 15:30:19 -08:00
TacticalCoderJay 6349503b00 feat: clear storage (#244) (#239)
* - fix: use oauth's user id instead of username
 - feat: add login only config for oauth

* Addresses tomato's concerns

* fix: catch same account on different user

* Add storage cleaning

Co-authored-by: dicedtomato <diced@users.noreply.github.com>

* Update src/components/pages/Manage/index.tsx

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>

* Update src/components/pages/Manage/index.tsx

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>

Co-authored-by: dicedtomato <diced@users.noreply.github.com>
Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2022-12-10 15:00:39 -08:00
diced 58e8c103b7 fix: change CORE_HTTPS To CORE_RETURN_HTTPS in compose file 2022-12-10 14:24:34 -08:00
diced 5d115afa71 feat: update deps & fix stuff 2022-12-10 14:19:53 -08:00
diced d8b308a18c fix: bind 2022-12-09 19:37:40 -08:00
diced 76267c00d5 fix: attempt to fix #243 2022-12-09 19:31:35 -08:00
diced 9648856052 chore: update prisma-binaries 2022-12-09 18:09:58 -08:00
diced d87e465a8e fix: undefined 2022-12-08 18:53:35 -08:00
diced 2c07d6719e feat: save showNonMedia checkbox value 2022-12-08 18:50:00 -08:00
diced 4c633eb60d fix: clean up 2022-12-07 23:11:19 -08:00
diced ba6580e4ef fix: route not found for nextjs api routes 2022-12-07 23:10:43 -08:00
diced c21d8f837e feat: built-in ssl support
- CORE_HTTPS is now CORE_RETURN_HTTPS
- SSL_(KEY/CERT/ALLOW_HTTP1)
2022-12-07 19:40:54 -08:00
diced eadfa09570 refactor: migrate to fastify
- (maybe) faster http server
- easy to develop on
2022-12-07 19:21:26 -08:00
TacticalCoderJay ea1a0b7fc8 fix: new oauth stuff (#240)
* - fix: use oauth's user id instead of username
 - feat: add login only config for oauth

* Addresses tomato's concerns

* fix: catch same account on different user
2022-12-06 21:40:13 -08:00
diced 9f797613d2 fix: render in files & fix typos 2022-12-06 17:19:02 -08:00
diced b728ff33ec fix: delete URLs after serving 2022-12-06 16:35:38 -08:00
diced 7dc036c6e4 fix: order urls by desc 2022-12-06 16:30:47 -08:00
diced 78135aac02 fix: dynamically import prism languages 2022-12-05 19:57:14 -08:00
diced 950018673f feat: render tex (katex) and markdown 2022-12-05 18:16:31 -08:00
diced cfdcf05135 feat: ability to use / for URLS_ROUTE 2022-12-03 14:31:13 -08:00
diced ace474eb2c feat(v3.6.3): version 2022-12-03 08:37:37 -08:00
diced 285ed8d56e fix: actually fix recursive thing 2022-12-03 08:02:16 -08:00
diced 738e25feda fix: exiftool not working on docker 2022-12-02 17:35:47 -08:00
diced 6d2d071293 fix: max call stack error 2022-12-02 08:37:13 -08:00
163 changed files with 9395 additions and 3792 deletions
+41
View File
@@ -0,0 +1,41 @@
{
"name": "Zipline Codespace",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"forwardPorts": [3000, 5432],
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {},
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {},
"ghcr.io/devcontainers/features/node:1": {}
},
"customizations": {
"vscode": {
"settings": {
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"files.autoSave": "afterDelay"
},
"extensions": ["prisma.prisma", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
}
},
"remoteUser": "zipline",
"remoteEnv": {
"CORE_DATABASE_URL": "postgres://postgres:postgres@localhost/zip10"
},
"portsAttributes": {
"3000": {
"label": "Zipline",
"onAutoForward": "openBrowser"
},
"5432": {
"label": "Postgres"
}
}
}
+22
View File
@@ -0,0 +1,22 @@
version: '3.8'
services:
app:
image: mcr.microsoft.com/vscode/devcontainers/javascript-node:0-18
volumes:
- ..:/workspace:cached
network_mode: service:db
command: sleep infinity
user: zipline
db:
image: postgres:latest
restart: unless-stopped
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DATABASE=postgres
volumes:
- pg_data:/var/lib/postgresql/data
volumes:
pg_data:
+1 -1
View File
@@ -41,5 +41,5 @@ yarn-error.log*
# zipline
config.toml
uploads/
uploads*/
dist/
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -5,5 +5,7 @@ nodeLinker: node-modules
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: "@yarnpkg/plugin-interactive-tools"
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
spec: "@yarnpkg/plugin-workspace-tools"
yarnPath: .yarn/releases/yarn-3.2.4.cjs
yarnPath: .yarn/releases/yarn-3.3.1.cjs
+1 -1
View File
@@ -12,7 +12,7 @@ Create an issue on GitHub, please include the following (if one of them is not a
## Feature requests
Create an issue on GitHub, please include the following:
Create an discussion on GitHub, please include the following:
- Breif explanation of the feature in the title (very breif please)
- How it would work (detailed, but optional)
+60 -49
View File
@@ -1,64 +1,75 @@
FROM ghcr.io/diced/prisma-binaries:4.5.x as prisma
# Use the Prisma binaries image as the first stage
FROM ghcr.io/diced/prisma-binaries:4.10.x as prisma
FROM node:alpine3.16 AS deps
RUN mkdir -p /prisma-engines
WORKDIR /build
# Use Alpine Linux as the second stage
FROM node:18-alpine3.16 as base
COPY .yarn .yarn
COPY package.json yarn.lock .yarnrc.yml ./
RUN yarn install --immutable
FROM node:alpine3.16 AS builder
WORKDIR /build
COPY --from=prisma /prisma-engines /prisma-engines
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
PRISMA_CLIENT_ENGINE_TYPE=binary
RUN apk add --no-cache openssl openssl-dev
COPY --from=deps /build/node_modules ./node_modules
COPY src ./src
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 mimes.json ./
ENV ZIPLINE_DOCKER_BUILD 1
ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
FROM node:alpine3.16 AS runner
# Set the working directory
WORKDIR /zipline
# Copy the necessary files from the project
COPY prisma ./prisma
COPY src ./src
COPY next.config.js ./next.config.js
COPY tsup.config.ts ./tsup.config.ts
COPY tsconfig.json ./tsconfig.json
COPY mimes.json ./mimes.json
COPY public ./public
FROM base as builder
COPY .yarn ./.yarn
COPY package*.json ./
COPY yarn*.lock ./
COPY .yarnrc.yml ./
# Copy the prisma binaries from prisma stage
COPY --from=prisma /prisma-engines /prisma-engines
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
PRISMA_CLIENT_ENGINE_TYPE=binary
PRISMA_CLIENT_ENGINE_TYPE=binary \
ZIPLINE_DOCKER_BUILD=true \
NEXT_TELEMETRY_DISABLED=1
RUN apk add --no-cache openssl openssl-dev
# Install production dependencies then temporarily save
RUN yarn workspaces focus --production --all
RUN cp -RL node_modules /tmp/node_modules
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
# Install the dependencies
RUN yarn install --immutable
COPY --from=builder /build/.next ./.next
COPY --from=builder /build/node_modules ./node_modules
# Run the build
RUN yarn build
COPY --from=builder /build/next.config.js ./next.config.js
COPY --from=builder /build/esbuild.config.js ./esbuild.config.js
COPY --from=builder /build/src ./src
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/prisma ./prisma
COPY --from=builder /build/tsconfig.json ./tsconfig.json
COPY --from=builder /build/package.json ./package.json
COPY --from=builder /build/mimes.json ./mimes.json
# Use Alpine Linux as the final image
FROM base
# Install the necessary packages
RUN apk add --no-cache perl procps tini
CMD ["node", "--enable-source-maps", "dist/server"]
COPY --from=builder /prisma-engines /prisma-engines
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
PRISMA_CLIENT_ENGINE_TYPE=binary \
NEXT_TELEMETRY_DISABLED=1
# Copy only the necessary files from the previous stage
COPY --from=builder /zipline/dist ./dist
COPY --from=builder /zipline/.next ./.next
COPY --from=builder /zipline/package.json ./package.json
COPY --from=builder /zipline/node_modules ./node_modules
COPY --from=builder /zipline/node_modules/.prisma/client ./node_modules/.prisma/client
COPY --from=builder /zipline/node_modules/@prisma/client ./node_modules/@prisma/client
# Copy Startup Script
COPY docker-entrypoint.sh /zipline
# Make Startup Script Executable
RUN chmod a+x /zipline/docker-entrypoint.sh
# Set the entrypoint to the startup script
ENTRYPOINT ["tini", "--", "/zipline/docker-entrypoint.sh"]
+5 -5
View File
@@ -8,9 +8,9 @@ A ShareX/file upload server that is easy to use, packed with features, and with
![GitHub last commit (branch)](https://img.shields.io/github/last-commit/diced/zipline/trunk?logo=git&logoColor=white&style=flat)
[![Discord](https://img.shields.io/discord/729771078196527176?color=%23777ed3&label=discord&logo=discord&logoColor=white&style=flat)](https://discord.gg/EAhCRfGxCF)
![Build](https://img.shields.io/github/workflow/status/diced/zipline/Build?logo=github&style=flat)
[![Docker Image (trunk)](https://img.shields.io/github/workflow/status/diced/zipline/Push%20Docker%20Images?label=Docker%20%28trunk%29&logo=github&style=flat)](https://github.com/diced/zipline/pkgs/container/zipline/?tag=trunk)
[![Docker Image (release)](https://img.shields.io/github/workflow/status/diced/zipline/Push%20Release%20Docker%20Images?label=Docker%20%28release%29&logo=github&style=flat)](https://github.com/diced/zipline/pkgs/container/zipline/?tag=latest)
![Build](https://img.shields.io/github/actions/workflow/status/diced/zipline/build.yml?logo=github&style=flat&branch=trunk)
[![Docker Image (trunk)](https://img.shields.io/github/actions/workflow/status/diced/zipline/docker.yml?label=Docker%20%28trunk%29&logo=github&style=flat&branch=trunk)](https://github.com/diced/zipline/pkgs/container/zipline/?tag=trunk)
[![Docker Image (release)](https://img.shields.io/github/actions/workflow/status/diced/zipline/docker-release.yml?label=Docker%20%28release%29&logo=github&style=flat&branch=trunk)](https://github.com/diced/zipline/pkgs/container/zipline/?tag=latest)
</div>
@@ -149,10 +149,10 @@ Create an issue on GitHub and use the template, please include the following (if
## Feature requests
Create an issue on GitHub, please include the following:
Create a discussion on GitHub, please include the following:
- Brief explanation of the feature in the title (very brief please)
- How it would work (detailed, but optional)
- How it would work (Be detailed!)
## Pull Requests (contributions to the codebase)
+1 -1
View File
@@ -4,7 +4,7 @@
| Version | Supported |
| ------- | ------------------ |
| 3.4.8 | :white_check_mark: |
| 3.6.x | :white_check_mark: |
| < 3 | :x: |
| < 2 | :x: |
+1 -3
View File
@@ -1,8 +1,7 @@
version: '3'
services:
postgres:
image: postgres
restart: always
image: postgres:15
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
@@ -21,7 +20,6 @@ services:
dockerfile: Dockerfile
ports:
- '3000:3000'
restart: unless-stopped
env_file:
- .env.local
volumes:
+4 -4
View File
@@ -1,8 +1,8 @@
version: '3'
services:
postgres:
image: postgres
restart: always
image: postgres:15
restart: unless-stopped
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
@@ -19,9 +19,9 @@ services:
image: ghcr.io/diced/zipline
ports:
- '3000:3000'
restart: always
restart: unless-stopped
environment:
- CORE_HTTPS=false
- CORE_RETURN_HTTPS=false
- CORE_SECRET=changethis
- CORE_HOST=0.0.0.0
- CORE_PORT=3000
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -e
node --enable-source-maps dist/index.js
-23
View File
@@ -1,23 +0,0 @@
const esbuild = require('esbuild');
const { existsSync } = require('fs');
const { rm } = require('fs/promises');
const { recursiveReadDir } = require('next/dist/lib/recursive-readdir');
(async () => {
if (existsSync('./dist')) {
await rm('./dist', { recursive: true });
}
const entryPoints = await recursiveReadDir('./src', /.*\.(ts)$/, /(themes|queries|pages)/);
await esbuild.build({
tsconfig: 'tsconfig.json',
outdir: 'dist',
platform: 'node',
entryPoints,
format: 'cjs',
resolveExtensions: ['.ts', '.js'],
write: true,
sourcemap: true,
});
})();
-9
View File
@@ -2,15 +2,6 @@
* @type {import('next').NextConfig}
**/
module.exports = {
async redirects() {
return [
{
source: '/',
destination: '/dashboard',
permanent: true,
},
];
},
images: {
domains: [
// For sharex icon in manage user
+55 -49
View File
@@ -1,92 +1,98 @@
{
"name": "zipline",
"version": "3.6.2",
"version": "3.7.0-rc4",
"license": "MIT",
"scripts": {
"dev": "npm-run-all build:server dev:run",
"dev:run": "cross-env DEBUG=true REACT_EDITOR=code NODE_ENV=development RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED=false node --enable-source-maps dist/server",
"dev:run": "cross-env DEBUG=true REACT_EDITOR=code NODE_ENV=development RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED=false node --enable-source-maps dist",
"build": "npm-run-all build:server build:schema build:next",
"build-ci": "cross-env ZIPLINE_DOCKER_BUILD=1 npm-run-all build:server build:schema build:next",
"build:server": "node esbuild.config.js",
"build:server": "tsup",
"build:next": "next build",
"build:schema": "prisma generate --schema=prisma/schema.prisma",
"format": "prettier --write ./src/**/*.{ts,tsx} ./*.{md,js,json,yml}",
"migrate:dev": "prisma migrate dev --create-only",
"start": "node dist/server",
"start": "node dist",
"lint": "next lint",
"docker:run": "docker-compose up -d",
"docker:up": "docker-compose up",
"docker:down": "docker-compose down",
"docker:build-dev": "docker-compose --file docker-compose.dev.yml up --build",
"scripts:read-config": "node dist/scripts/read-config",
"scripts:import-dir": "node dist/scripts/import-dir",
"scripts:list-users": "node dist/scripts/list-users",
"scripts:set-user": "node dist/scripts/set-user"
"docker:up-dev": "docker-compose --file docker-compose.dev.yml up",
"docker:down-dev": "docker-compose --file docker-compose.dev.yml down",
"scripts:read-config": "node --enable-source-maps dist/scripts/read-config",
"scripts:import-dir": "node --enable-source-maps dist/scripts/import-dir",
"scripts:list-users": "node --enable-source-maps dist/scripts/list-users",
"scripts:set-user": "node --enable-source-maps dist/scripts/set-user",
"scripts:clear-zero-byte": "node --enable-source-maps dist/scripts/clear-zero-byte",
"scripts:query-size": "node --enable-source-maps dist/scripts/query-size"
},
"dependencies": {
"@dicedtomato/mantine-data-grid": "0.0.23",
"@emotion/react": "^11.10.5",
"@emotion/react": "^11.10.6",
"@emotion/server": "^11.10.0",
"@mantine/core": "^5.6.3",
"@mantine/dropzone": "^5.6.3",
"@mantine/form": "^5.6.3",
"@mantine/hooks": "^5.6.3",
"@mantine/modals": "^5.6.3",
"@mantine/next": "^5.6.3",
"@mantine/notifications": "^5.6.3",
"@mantine/nprogress": "^5.6.3",
"@mantine/prism": "^5.6.3",
"@prisma/client": "^4.5.0",
"@prisma/internals": "^4.5.0",
"@prisma/migrate": "^4.5.0",
"@sapphire/shapeshift": "^3.7.0",
"@tanstack/react-query": "^4.13.0",
"argon2": "^0.30.1",
"chart.js": "^3.9.1",
"chartjs-plugin-datalabels": "^2.1.0",
"color-hash": "^2.0.1",
"colorette": "^2.0.19",
"@mantine/core": "^6.0.0",
"@mantine/dropzone": "^6.0.0",
"@mantine/form": "^6.0.0",
"@mantine/hooks": "^6.0.0",
"@mantine/modals": "^6.0.0",
"@mantine/next": "^6.0.0",
"@mantine/notifications": "^6.0.0",
"@mantine/prism": "^6.0.0",
"@prisma/client": "^4.10.1",
"@prisma/internals": "^4.10.1",
"@prisma/migrate": "^4.10.1",
"@sapphire/shapeshift": "^3.8.1",
"@tanstack/react-query": "^4.24.10",
"argon2": "^0.30.3",
"cookie": "^0.5.0",
"dayjs": "^1.11.6",
"dayjs": "^1.11.7",
"dotenv": "^16.0.3",
"dotenv-expand": "^9.0.0",
"exiftool-vendored": "^18.6.0",
"dotenv-expand": "^10.0.0",
"exiftool-vendored": "^21.2.0",
"fastify": "^4.13.0",
"fastify-plugin": "^4.5.0",
"fflate": "^0.7.4",
"find-my-way": "^7.3.1",
"find-my-way": "^7.5.0",
"katex": "^0.16.4",
"mantine-datatable": "^1.8.6",
"minio": "^7.0.32",
"ms": "canary",
"multer": "^1.4.5-lts.1",
"next": "^13.0.0",
"next": "^13.2.1",
"otplib": "^12.0.1",
"prisma": "^4.5.0",
"prisma": "^4.10.1",
"prismjs": "^1.29.0",
"qrcode": "^1.5.1",
"react": "^18.2.0",
"react-chartjs-2": "^4.3.1",
"react-dom": "^18.2.0",
"react-feather": "^2.0.10",
"react-markdown": "^8.0.5",
"recharts": "^2.4.3",
"recoil": "^0.7.6",
"sharp": "^0.31.1"
"remark-gfm": "^3.0.1",
"sharp": "^0.31.3"
},
"devDependencies": {
"@types/cookie": "^0.5.1",
"@types/minio": "^7.0.14",
"@types/katex": "^0.16.0",
"@types/minio": "^7.0.16",
"@types/multer": "^1.4.7",
"@types/node": "^18.11.7",
"@types/node": "^18.14.2",
"@types/qrcode": "^1.5.0",
"@types/react": "^18.0.24",
"@types/sharp": "^0.31.0",
"@types/react": "^18.0.28",
"@types/sharp": "^0.31.1",
"cross-env": "^7.0.3",
"esbuild": "^0.15.12",
"eslint": "^8.26.0",
"eslint-config-next": "^13.0.0",
"eslint-config-prettier": "^8.5.0",
"eslint": "^8.35.0",
"eslint-config-next": "^13.2.1",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-prettier": "^4.2.1",
"npm-run-all": "^4.1.5",
"prettier": "^2.7.1",
"typescript": "^4.8.4"
"prettier": "^2.8.4",
"tsup": "^6.6.3",
"typescript": "^4.9.5"
},
"repository": {
"type": "git",
"url": "https://github.com/diced/zipline.git"
},
"packageManager": "yarn@3.2.4"
"packageManager": "yarn@3.3.1"
}
@@ -0,0 +1,12 @@
/*
Warnings:
- A unique constraint covering the columns `[provider,oauthId]` on the table `OAuth` will be added. If there are existing duplicate values, this will fail.
- Added the required column `oauthId` to the `OAuth` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "OAuth" ADD COLUMN "oauthId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "OAuth_provider_oauthId_key" ON "OAuth"("provider", "oauthId");
@@ -0,0 +1,13 @@
/*
Warnings:
- You are about to drop the column `embedColor` on the `User` table. All the data in the column will be lost.
- You are about to drop the column `embedSiteName` on the `User` table. All the data in the column will be lost.
- You are about to drop the column `embedTitle` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "embedColor",
DROP COLUMN "embedSiteName",
DROP COLUMN "embedTitle",
ADD COLUMN "embed" JSONB NOT NULL DEFAULT '{}';
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "Image" RENAME COLUMN "created_at" TO "createdAt";
-- AlterTable
ALTER TABLE "Image" RENAME COLUMN "expires_at" TO "expiresAt";
-- AlterTable
ALTER TABLE "Image" RENAME COLUMN "file" TO "name";
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Url" RENAME COLUMN "created_at" TO "createdAt";
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Stats" RENAME COLUMN "created_at" TO "createdAt";
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Invite" RENAME COLUMN "created_at" TO "createdAt";
-- AlterTable
ALTER TABLE "Invite" RENAME COLUMN "expires_at" TO "expiresAt";
@@ -0,0 +1,19 @@
-- AlterEnum
ALTER TYPE "ImageFormat" RENAME TO "FileNameFormat";
-- AlterTable
ALTER TABLE "Image" RENAME TO "File";
-- AlterTable
ALTER TABLE "InvisibleImage" RENAME TO "InvisibleFile";
-- AlterTable
ALTER TABLE "InvisibleFile" RENAME COLUMN "imageId" TO "fileId";
-- AlterForeignKey
ALTER TABLE "InvisibleFile" RENAME CONSTRAINT "InvisibleImage_imageId_fkey" TO "InvisibleFile_fileId_fkey";
ALTER INDEX "InvisibleImage_imageId_key" RENAME TO "InvisibleFile_fileId_key";
-- AlterForeignKey
ALTER TABLE "InvisibleFile" RENAME CONSTRAINT "InvisibleImage_pkey" TO "InvisibleFile_pkey";
ALTER TABLE "File" RENAME CONSTRAINT "Image_pkey" TO "File_pkey";
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "File" ADD COLUMN "originalName" TEXT;
-- RenameForeignKey
ALTER TABLE "File" RENAME CONSTRAINT "Image_userId_fkey" TO "File_userId_fkey";
-- RenameIndex
ALTER INDEX "InvisibleImage_invis_key" RENAME TO "InvisibleFile_invis_key";
@@ -0,0 +1,19 @@
-- AlterTable
ALTER TABLE "File" ADD COLUMN "folderId" INTEGER;
-- CreateTable
CREATE TABLE "Folder" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "Folder_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "File" ADD CONSTRAINT "File_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Folder" ADD COLUMN "public" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "File" ADD COLUMN "size" INTEGER NOT NULL DEFAULT 0;
+62 -37
View File
@@ -16,81 +16,103 @@ model User {
administrator Boolean @default(false)
superAdmin Boolean @default(false)
systemTheme String @default("system")
embedTitle String?
embedColor String @default("#2f3136")
embedSiteName String? @default("{image.file} • {user.name}")
embed Json @default("{}")
ratelimit DateTime?
totpSecret String?
domains String[]
oauth OAuth[]
images Image[]
files File[]
urls Url[]
Invite Invite[]
Folder Folder[]
}
enum ImageFormat {
model Folder {
id Int @id @default(autoincrement())
name String
public Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
files File[]
}
enum FileNameFormat {
UUID
DATE
RANDOM
NAME
}
model Image {
id Int @id @default(autoincrement())
file String
mimetype String @default("image/png")
created_at DateTime @default(now())
expires_at DateTime?
maxViews Int?
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], onDelete: SetNull)
userId Int?
model File {
id Int @id @default(autoincrement())
name String
originalName String?
mimetype String @default("image/png")
createdAt DateTime @default(now())
size Int @default(0)
expiresAt DateTime?
maxViews Int?
views Int @default(0)
favorite Boolean @default(false)
embed Boolean @default(false)
password String?
invisible InvisibleFile?
format FileNameFormat @default(RANDOM)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId Int?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
folderId Int?
}
model InvisibleImage {
id Int @id @default(autoincrement())
invis String @unique
imageId Int @unique
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
model InvisibleFile {
id Int @id @default(autoincrement())
invis String @unique
fileId Int @unique
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
}
model Url {
id String @id @unique
destination String
vanity String?
created_at DateTime @default(now())
createdAt DateTime @default(now())
maxViews Int?
views Int @default(0)
invisible InvisibleUrl?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId Int?
}
model InvisibleUrl {
id Int @id @default(autoincrement())
invis String @unique
urlId String @unique
url Url @relation(fields: [urlId], references: [id], onDelete: Cascade)
}
model Stats {
id Int @id @default(autoincrement())
created_at DateTime @default(now())
data Json
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
data Json
}
model Invite {
id Int @id @default(autoincrement())
code String @unique
created_at DateTime @default(now())
expires_at DateTime?
used Boolean @default(false)
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
code String @unique
createdAt DateTime @default(now())
expiresAt DateTime?
used Boolean @default(false)
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
createdById Int
}
@@ -100,8 +122,11 @@ model OAuth {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
username String
oauthId String?
token String
refresh String?
@@unique([provider, oauthId])
}
enum OauthProviders {
+26 -5
View File
@@ -1,15 +1,36 @@
import { createStyles, MantineSize, Textarea } from '@mantine/core';
import { createStyles, Textarea } from '@mantine/core';
import { useEffect } from 'react';
const useStyles = createStyles((theme, { size }: { size: MantineSize }) => ({
const useStyles = createStyles(() => ({
input: {
fontFamily: 'monospace',
fontSize: theme.fn.size({ size, sizes: theme.fontSizes }) - 2,
height: '80vh',
},
}));
export default function CodeInput({ ...props }) {
const { classes } = useStyles({ size: 'md' }, { name: 'CodeInput' });
const { classes } = useStyles(null, { name: 'CodeInput' });
return <Textarea classNames={{ input: classes.input }} autoComplete='nope' {...props} />;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Tab') {
if (document.activeElement?.tagName !== 'TEXTAREA') return;
e.preventDefault();
const target = e.target as HTMLTextAreaElement;
const start = target.selectionStart;
const end = target.selectionEnd;
target.value = `${target.value.substring(0, start)} ${target.value.substring(end)}`;
target.selectionStart = target.selectionEnd = start + 2;
target.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
return <Textarea classNames={{ input: classes.input }} {...props} />;
}
-200
View File
@@ -1,200 +0,0 @@
import { Button, Card, Group, LoadingOverlay, Modal, Stack, Text, Title, Tooltip } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
import { relativeTime } from 'lib/utils/client';
import { useState } from 'react';
import {
CalendarIcon,
ClockIcon,
CopyIcon,
CrossIcon,
DeleteIcon,
ExternalLinkIcon,
EyeIcon,
FileIcon,
HashIcon,
ImageIcon,
StarIcon,
} from './icons';
import Link from './Link';
import MutedText from './MutedText';
import Type from './Type';
export function FileMeta({ Icon, title, subtitle, ...other }) {
return other.tooltip ? (
<Group>
<Icon size={24} />
<Tooltip label={other.tooltip}>
<Stack spacing={1}>
<Text>{title}</Text>
<MutedText size='md'>{subtitle}</MutedText>
</Stack>
</Tooltip>
</Group>
) : (
<Group>
<Icon size={24} />
<Stack spacing={1}>
<Text>{title}</Text>
<MutedText size='md'>{subtitle}</MutedText>
</Stack>
</Group>
);
}
export default function File({ image, disableMediaPreview, exifEnabled }) {
const [open, setOpen] = useState(false);
const deleteFile = useFileDelete();
const favoriteFile = useFileFavorite();
const clipboard = useClipboard();
const loading = deleteFile.isLoading || favoriteFile.isLoading;
const handleDelete = async () => {
deleteFile.mutate(image.id, {
onSuccess: () => {
showNotification({
title: 'File Deleted',
message: '',
color: 'green',
icon: <DeleteIcon />,
});
},
onError: (res: any) => {
showNotification({
title: 'Failed to delete file',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
},
onSettled: () => {
setOpen(false);
},
});
};
const handleCopy = () => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${image.url}`);
setOpen(false);
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
};
const handleFavorite = async () => {
favoriteFile.mutate(
{ id: image.id, favorite: !image.favorite },
{
onSuccess: () => {
showNotification({
title: 'Image is now ' + (!image.favorite ? 'favorited' : 'unfavorited'),
message: '',
icon: <StarIcon />,
});
},
onError: (res: any) => {
showNotification({
title: 'Failed to favorite file',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
},
}
);
};
return (
<>
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>{image.file}</Title>} size='xl'>
<LoadingOverlay visible={loading} />
<Stack>
<Type
file={image}
src={`/r/${image.file}`}
alt={image.file}
popup
sx={{ minHeight: 200 }}
style={{ minHeight: 200 }}
disableMediaPreview={false}
/>
<Stack>
<FileMeta Icon={FileIcon} title='Name' subtitle={image.file} />
<FileMeta Icon={ImageIcon} title='Type' subtitle={image.mimetype} />
<FileMeta Icon={EyeIcon} title='Views' subtitle={image?.views?.toLocaleString()} />
{image.maxViews && (
<FileMeta
Icon={EyeIcon}
title='Max views'
subtitle={image?.maxViews?.toLocaleString()}
tooltip={`This file will be deleted after being viewed ${image?.maxViews?.toLocaleString()} times.`}
/>
)}
<FileMeta
Icon={CalendarIcon}
title='Uploaded'
subtitle={relativeTime(new Date(image.created_at))}
tooltip={new Date(image?.created_at).toLocaleString()}
/>
{image.expires_at && (
<FileMeta
Icon={ClockIcon}
title='Expires'
subtitle={relativeTime(new Date(image.expires_at))}
tooltip={new Date(image.expires_at).toLocaleString()}
/>
)}
<FileMeta Icon={HashIcon} title='ID' subtitle={image.id} />
</Stack>
</Stack>
<Group position='right' mt='md'>
{exifEnabled && (
<Link href={`/dashboard/metadata/${image.id}`} target='_blank' rel='noopener noreferrer'>
<Button leftIcon={<ExternalLinkIcon />}>View Metadata</Button>
</Link>
)}
<Button onClick={handleCopy}>Copy URL</Button>
<Button onClick={handleDelete}>Delete</Button>
<Button onClick={handleFavorite}>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
<Link href={image.url} target='_blank'>
<Button rightIcon={<ExternalLinkIcon />}>Open</Button>
</Link>
</Group>
</Modal>
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
<Card.Section>
<LoadingOverlay visible={loading} />
<Type
file={image}
sx={{
minHeight: 200,
maxHeight: 320,
fontSize: 70,
width: '100%',
cursor: 'pointer',
}}
style={{
minHeight: 200,
maxHeight: 320,
fontSize: 70,
width: '100%',
cursor: 'pointer',
}}
src={`/r/${image.file}`}
alt={image.file}
onClick={() => setOpen(true)}
disableMediaPreview={disableMediaPreview}
/>
</Card.Section>
</Card>
</>
);
}
+348
View File
@@ -0,0 +1,348 @@
import {
ActionIcon,
Group,
LoadingOverlay,
Modal,
Select,
SimpleGrid,
Stack,
Title,
Tooltip,
} from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import useFetch from 'hooks/useFetch';
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
import { useFolders } from 'lib/queries/folders';
import { bytesToHuman } from 'lib/utils/bytes';
import { relativeTime } from 'lib/utils/client';
import { useState } from 'react';
import { FileMeta } from '.';
import {
CalendarIcon,
ClockIcon,
CopyIcon,
CrossIcon,
DeleteIcon,
DownloadIcon,
ExternalLinkIcon,
EyeIcon,
FileIcon,
FolderMinusIcon,
FolderPlusIcon,
HardDriveIcon,
HashIcon,
ImageIcon,
InfoIcon,
StarIcon,
} from '../icons';
import Type from '../Type';
export default function FileModal({
open,
setOpen,
file,
loading,
refresh,
reducedActions = false,
exifEnabled,
}: {
open: boolean;
setOpen: (open: boolean) => void;
file: any;
loading: boolean;
refresh: () => void;
reducedActions?: boolean;
exifEnabled?: boolean;
}) {
const deleteFile = useFileDelete();
const favoriteFile = useFileFavorite();
const folders = useFolders();
const [overrideRender, setOverrideRender] = useState(false);
const clipboard = useClipboard();
const handleDelete = async () => {
deleteFile.mutate(file.id, {
onSuccess: () => {
showNotification({
title: 'File Deleted',
message: '',
color: 'green',
icon: <DeleteIcon />,
});
},
onError: (res: any) => {
showNotification({
title: 'Failed to delete file',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
},
onSettled: () => {
setOpen(false);
},
});
};
const handleCopy = () => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${file.url}`);
setOpen(false);
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
else
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
};
const handleFavorite = async () => {
favoriteFile.mutate(
{ id: file.id, favorite: !file.favorite },
{
onSuccess: () => {
showNotification({
title: 'The file is now ' + (!file.favorite ? 'favorited' : 'unfavorited'),
message: '',
icon: <StarIcon />,
});
},
onError: (res: any) => {
showNotification({
title: 'Failed to favorite file',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
},
}
);
};
const inFolder = file.folderId;
const removeFromFolder = async () => {
const res = await useFetch('/api/user/folders/' + file.folderId, 'DELETE', {
file: Number(file.id),
});
refresh();
if (!res.error) {
showNotification({
title: 'Removed from folder',
message: res.name,
color: 'green',
icon: <FolderMinusIcon />,
});
} else {
showNotification({
title: 'Failed to remove from folder',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
}
};
const addToFolder = async (t) => {
const res = await useFetch('/api/user/folders/' + t, 'POST', {
file: Number(file.id),
});
refresh();
if (!res.error) {
showNotification({
title: 'Added to folder',
message: res.name,
color: 'green',
icon: <FolderPlusIcon />,
});
} else {
showNotification({
title: 'Failed to add to folder',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
}
};
const createFolder = (t) => {
useFetch('/api/user/folders', 'POST', {
name: t,
add: [Number(file.id)],
}).then((res) => {
refresh();
if (!res.error) {
showNotification({
title: 'Created & added to folder',
message: res.name,
color: 'green',
icon: <FolderPlusIcon />,
});
} else {
showNotification({
title: 'Failed to create folder',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
}
});
return { value: t, label: t };
};
return (
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>{file.name}</Title>} size='xl'>
<LoadingOverlay visible={loading} />
<Stack>
<Type
file={file}
src={`/r/${encodeURI(file.name)}`}
alt={file.name}
popup
sx={{ minHeight: 200 }}
style={{ minHeight: 200 }}
disableMediaPreview={false}
overrideRender={overrideRender}
setOverrideRender={setOverrideRender}
/>
<SimpleGrid
my='md'
cols={3}
breakpoints={[
{ maxWidth: 600, cols: 1 },
{ maxWidth: 900, cols: 2 },
{ maxWidth: 1200, cols: 3 },
]}
>
<FileMeta Icon={FileIcon} title='Name' subtitle={file.name} />
<FileMeta Icon={ImageIcon} title='Type' subtitle={file.mimetype} />
<FileMeta Icon={HardDriveIcon} title='Size' subtitle={bytesToHuman(file.size || 0)} />
<FileMeta Icon={EyeIcon} title='Views' subtitle={file?.views?.toLocaleString()} />
{file.maxViews && (
<FileMeta
Icon={EyeIcon}
title='Max views'
subtitle={file?.maxViews?.toLocaleString()}
tooltip={`This file will be deleted after being viewed ${file?.maxViews?.toLocaleString()} times.`}
/>
)}
<FileMeta
Icon={CalendarIcon}
title='Uploaded'
subtitle={relativeTime(new Date(file.createdAt))}
tooltip={new Date(file?.createdAt).toLocaleString()}
/>
{file.expiresAt && !reducedActions && (
<FileMeta
Icon={ClockIcon}
title='Expires'
subtitle={relativeTime(new Date(file.expiresAt))}
tooltip={new Date(file.expiresAt).toLocaleString()}
/>
)}
<FileMeta Icon={HashIcon} title='ID' subtitle={file.id} />
</SimpleGrid>
</Stack>
<Group position='apart' my='md'>
<Group position='left'>
{exifEnabled && !reducedActions && (
<Tooltip label='View Metadata'>
<ActionIcon
color='blue'
variant='filled'
onClick={() => window.open(`/dashboard/metadata/${file.id}`, '_blank')}
>
<InfoIcon />
</ActionIcon>
</Tooltip>
)}
{reducedActions ? null : inFolder && !folders.isLoading ? (
<Tooltip
label={`Remove from folder "${folders.data.find((f) => f.id === file.folderId)?.name ?? ''}"`}
>
<ActionIcon color='red' variant='filled' onClick={removeFromFolder} loading={folders.isLoading}>
<FolderMinusIcon />
</ActionIcon>
</Tooltip>
) : (
<Tooltip label='Add to folder'>
<Select
onChange={addToFolder}
placeholder='Add to folder'
data={[
...(folders.data ? folders.data : []).map((folder) => ({
value: String(folder.id),
label: `${folder.id}: ${folder.name}`,
})),
]}
searchable
creatable
getCreateLabel={(query) => `Create folder "${query}"`}
onCreate={createFolder}
/>
</Tooltip>
)}
</Group>
<Group position='right'>
{reducedActions ? null : (
<>
<Tooltip label='Delete file'>
<ActionIcon color='red' variant='filled' onClick={handleDelete}>
<DeleteIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={file.favorite ? 'Unfavorite' : 'Favorite'}>
<ActionIcon
color={file.favorite ? 'yellow' : 'gray'}
variant='filled'
onClick={handleFavorite}
>
<StarIcon />
</ActionIcon>
</Tooltip>
</>
)}
<Tooltip label='Open in new tab'>
<ActionIcon color='blue' variant='filled' onClick={() => window.open(file.url, '_blank')}>
<ExternalLinkIcon />
</ActionIcon>
</Tooltip>
<Tooltip label='Copy URL'>
<ActionIcon color='blue' variant='filled' onClick={handleCopy}>
<CopyIcon />
</ActionIcon>
</Tooltip>
<Tooltip label='Download'>
<ActionIcon
color='blue'
variant='filled'
onClick={() => window.open(`/r/${encodeURI(file.name)}?download=true`, '_blank')}
>
<DownloadIcon />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Modal>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { Card, Group, LoadingOverlay, Stack, Text, Tooltip } from '@mantine/core';
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
import { useFolders } from 'lib/queries/folders';
import { useState } from 'react';
import MutedText from '../MutedText';
import Type from '../Type';
import FileModal from './FileModal';
export function FileMeta({ Icon, title, subtitle, ...other }) {
return other.tooltip ? (
<Group>
<Icon size={24} />
<Tooltip label={other.tooltip}>
<Stack spacing={1}>
<Text>{title}</Text>
<MutedText size='md'>{subtitle}</MutedText>
</Stack>
</Tooltip>
</Group>
) : (
<Group>
<Icon size={24} />
<Stack spacing={1}>
<Text>{title}</Text>
<MutedText size='md'>{subtitle}</MutedText>
</Stack>
</Group>
);
}
export default function File({
image,
disableMediaPreview,
exifEnabled,
refreshImages,
reducedActions = false,
}) {
const [open, setOpen] = useState(false);
const deleteFile = useFileDelete();
const favoriteFile = useFileFavorite();
const loading = deleteFile.isLoading || favoriteFile.isLoading;
const folders = useFolders();
const refresh = () => {
refreshImages();
folders.refetch();
};
return (
<>
<FileModal
open={open}
setOpen={setOpen}
file={image}
loading={loading}
refresh={refresh}
reducedActions={reducedActions}
exifEnabled={exifEnabled}
/>
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
<Card.Section>
<LoadingOverlay visible={loading} />
<Type
file={image}
sx={{
minHeight: 200,
maxHeight: 320,
fontSize: 70,
width: '100%',
cursor: 'pointer',
}}
style={{
minHeight: 200,
maxHeight: 320,
fontSize: 70,
width: '100%',
cursor: 'pointer',
}}
src={`/r/${encodeURI(image.name)}`}
alt={image.name}
onClick={() => setOpen(true)}
disableMediaPreview={disableMediaPreview}
/>
</Card.Section>
</Card>
</>
);
}
+195 -208
View File
@@ -7,22 +7,21 @@ import {
Group,
Header,
Image,
Input,
MediaQuery,
Menu,
Navbar,
NavLink,
Paper,
Popover,
rem,
ScrollArea,
Select,
Stack,
Text,
Title,
Tooltip,
UnstyledButton,
useMantineTheme,
} from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { useClipboard, useMediaQuery } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import useFetch from 'hooks/useFetch';
@@ -42,6 +41,7 @@ import {
DiscordIcon,
ExternalLinkIcon,
FileIcon,
FolderIcon,
GitHubIcon,
GoogleIcon,
HomeIcon,
@@ -56,62 +56,15 @@ import {
} from './icons';
import { friendlyThemeName, themes } from './Theming';
function MenuItemLink(props) {
return (
<Link href={props.href} passHref legacyBehavior>
<MenuItem {...props} />
</Link>
);
}
export type NavbarItems = {
icon: React.ReactNode;
text: string;
link?: string;
children?: NavbarItems[];
if?: (user: any, props: any) => boolean;
};
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': !props.noClick
? {
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],
}
: null,
})}
{...props}
>
<Group noWrap>
<Box
sx={(theme) => ({
marginRight: theme.spacing.xs / 4,
paddingLeft: theme.spacing.xs / 2,
'& *': {
display: 'block',
},
})}
>
{props.icon}
</Box>
<Text size='sm'>{props.children}</Text>
</Group>
</UnstyledButton>
);
}
const items = [
const items: NavbarItems[] = [
{
icon: <HomeIcon size={18} />,
text: 'Home',
@@ -122,6 +75,11 @@ const items = [
text: 'Files',
link: '/dashboard/files',
},
{
icon: <FolderIcon size={18} />,
text: 'Folders',
link: '/dashboard/folders',
},
{
icon: <ActivityIcon size={18} />,
text: 'Stats',
@@ -135,27 +93,37 @@ const items = [
{
icon: <UploadIcon size={18} />,
text: 'Upload',
link: '/dashboard/upload/file',
children: [
{
icon: <UploadIcon size={18} />,
text: 'File',
link: '/dashboard/upload/file',
},
{
icon: <TypeIcon size={18} />,
text: 'Text',
link: '/dashboard/upload/text',
},
],
},
{
icon: <TypeIcon size={18} />,
text: 'Upload Text',
link: '/dashboard/upload/text',
},
];
const admin_items = [
{
icon: <UserIcon size={18} />,
text: 'Users',
link: '/dashboard/users',
if: () => true,
},
{
icon: <TagIcon size={18} />,
text: 'Invites',
link: '/dashboard/invites',
if: (props) => props.invites,
text: 'Administration',
if: (user, _) => user.administrator as boolean,
children: [
{
icon: <UserIcon size={18} />,
text: 'Users',
link: '/dashboard/users',
if: () => true,
},
{
icon: <TagIcon size={18} />,
text: 'Invites',
link: '/dashboard/invites',
if: (_, props) => props.invites,
},
],
},
];
@@ -180,7 +148,6 @@ export default function Layout({ children, props }) {
const [systemTheme, setSystemTheme] = useState(user.systemTheme ?? 'system');
const version = useVersion();
const [opened, setOpened] = useState(false); // navigation open
const [open, setOpen] = useState(false); // manage acc dropdown
const avatar = user?.avatar ?? null;
const router = useRouter();
@@ -250,13 +217,29 @@ export default function Layout({ children, props }) {
labels: { confirm: 'Copy', cancel: 'Cancel' },
onConfirm: async () => {
clipboard.copy(token);
showNotification({
title: 'Token Copied',
message: 'Your token has been copied to your clipboard.',
color: 'green',
icon: <CheckIcon />,
});
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: (
<Text size='sm'>
Zipline is unable to copy to clipboard due to security reasons. However, you can still copy
the token manually.
<br />
<Group position='left' spacing='sm'>
<Text>Your token is:</Text>
<Input size='sm' onFocus={(e) => e.target.select()} type='text' value={token} />
</Group>
</Text>
),
color: 'red',
});
else
showNotification({
title: 'Token Copied',
message: 'Your token has been copied to your clipboard.',
color: 'green',
icon: <CheckIcon />,
});
modals.closeAll();
},
@@ -269,39 +252,42 @@ export default function Layout({ children, props }) {
navbar={
<Navbar pt='sm' hiddenBreakpoint='sm' hidden={!opened} width={{ sm: 200, lg: 230 }}>
<Navbar.Section grow component={ScrollArea}>
{items.map(({ icon, text, link }) => (
<Link href={link} key={text} passHref legacyBehavior>
<NavLink
component='a'
label={text}
icon={icon}
active={router.pathname === link}
variant='light'
/>
</Link>
))}
{user.administrator && (
<NavLink
label='Administration'
icon={<SettingsIcon />}
childrenOffset={28}
defaultOpened={admin_items.map((x) => x.link).includes(router.pathname)}
>
{admin_items
.filter((x) => x.if(props))
.map(({ icon, text, link }) => (
<Link href={link} key={text} passHref legacyBehavior>
<NavLink
component='a'
label={text}
icon={icon}
active={router.pathname === link}
variant='light'
/>
</Link>
))}
</NavLink>
)}
{items
.filter((x) => (x.if ? x.if(user, props) : true))
.map(({ icon, text, link, children }) =>
children ? (
<NavLink
key={text}
label={text}
icon={icon}
defaultOpened={children.map((x) => x.link).includes(router.pathname)}
>
{children
.filter((x) => (x.if ? x.if(user, props) : true))
.map(({ icon, text, link }) => (
<Link href={link} key={text} passHref legacyBehavior>
<NavLink
component='a'
label={text}
icon={icon}
active={router.pathname === link}
variant='light'
/>
</Link>
))}
</NavLink>
) : (
<Link href={link} key={text} passHref legacyBehavior>
<NavLink
component='a'
label={text}
icon={icon}
active={router.pathname === link}
variant='light'
/>
</Link>
)
)}
</Navbar.Section>
<Navbar.Section>
{external_links.length
@@ -322,9 +308,11 @@ export default function Layout({ children, props }) {
<Navbar.Section>
<Tooltip
label={
version.data.local !== version.data.upstream
? `You are running an outdated version of Zipline, refer to the docs on how to update to ${version.data.upstream}`
: 'You are running the latest version of Zipline'
version.data.update
? `There is a new ${version.data.updateToType} version: ${
version.data.versions[version.data.updateToType]
}`
: `You are running the latest ${version.data.isUpstream ? 'upstream' : 'stable'} version`
}
>
<Badge
@@ -332,9 +320,9 @@ export default function Layout({ children, props }) {
radius='md'
size='lg'
variant='dot'
color={version.data.local !== version.data.upstream ? 'red' : 'primary'}
color={version.data.update ? 'red' : 'primary'}
>
{version.data.local}
{version.data.versions.current}
</Badge>
</Tooltip>
</Navbar.Section>
@@ -354,99 +342,94 @@ export default function Layout({ children, props }) {
</MediaQuery>
<Title ml='sm'>{title}</Title>
<Box sx={{ marginLeft: 'auto', marginRight: 0 }}>
<Popover position='bottom-end' opened={open} onClose={() => setOpen(false)}>
<Popover.Target>
<Menu
styles={{
item: {
'@media (max-width: 768px)': {
padding: '1rem',
width: '80vw',
},
},
}}
>
<Menu.Target>
<Button
leftIcon={avatar ? <Image src={avatar} height={32} radius='md' /> : <SettingsIcon />}
onClick={() => setOpen((o) => !o)}
sx={(t) => ({
backgroundColor: 'inherit',
'&:hover': {
backgroundColor: t.other.hover,
},
color: t.colorScheme === 'dark' ? 'white' : 'black',
})}
variant='subtle'
color='gray'
compact
size='xl'
p='sm'
>
{user.username}
</Button>
</Popover.Target>
<Popover.Dropdown p={4} mr='md' sx={{ minWidth: '200px' }}>
<Stack spacing={2}>
<Menu.Label>
{user.username} ({user.id}){' '}
{user.administrator && user.username !== 'administrator' ? '(Administrator)' : ''}
</Menu.Label>
<MenuItemLink icon={<SettingsIcon />} href='/dashboard/manage'>
Manage Account
</MenuItemLink>
<MenuItem
icon={<CopyIcon />}
onClick={() => {
setOpen(false);
openCopyToken();
}}
>
Copy Token
</MenuItem>
<MenuItem
icon={<DeleteIcon />}
onClick={() => {
setOpen(false);
openResetToken();
}}
color='red'
>
Reset Token
</MenuItem>
<MenuItemLink icon={<LogoutIcon />} href='/auth/logout' color='red'>
Logout
</MenuItemLink>
<Menu.Divider />
<>
{oauth_providers
.filter((x) =>
user.oauth
?.map(({ provider }) => provider.toLowerCase())
.includes(x.name.toLowerCase())
)
.map(({ name, Icon }, i) => (
<>
<MenuItem
sx={{ '&:hover': { backgroundColor: 'inherit' } }}
key={i}
py={5}
px={4}
icon={<Icon size={18} colorScheme={theme.colorScheme} />}
>
Logged in with {capitalize(name)}
</MenuItem>
</>
))}
{oauth_providers.filter((x) =>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>
{user.username} ({user.id}){' '}
{user.administrator && user.username !== 'administrator' ? '(Administrator)' : ''}
</Menu.Label>
<Menu.Item component={Link} icon={<SettingsIcon />} href='/dashboard/manage'>
Manage Account
</Menu.Item>
<Menu.Item
icon={<CopyIcon />}
onClick={() => {
openCopyToken();
}}
>
Copy Token
</Menu.Item>
<Menu.Item
icon={<DeleteIcon />}
onClick={() => {
openResetToken();
}}
color='red'
>
Reset Token
</Menu.Item>
<Menu.Item component={Link} icon={<LogoutIcon />} href='/auth/logout' color='red'>
Logout
</Menu.Item>
<Menu.Divider />
<>
{oauth_providers
.filter((x) =>
user.oauth
?.map(({ provider }) => provider.toLowerCase())
.includes(x.name.toLowerCase())
).length ? (
<Menu.Divider />
) : null}
</>
<MenuItem icon={<PencilIcon />}>
<Select
size='xs'
data={Object.keys(themes).map((t) => ({
value: t,
label: friendlyThemeName[t],
}))}
value={systemTheme}
onChange={handleUpdateTheme}
/>
</MenuItem>
</Stack>
</Popover.Dropdown>
</Popover>
)
.map(({ name, Icon }, i) => (
<>
<Menu.Item
closeMenuOnClick={false}
key={i}
icon={<Icon size={18} colorScheme={theme.colorScheme} />}
>
Logged in with {capitalize(name)}
</Menu.Item>
</>
))}
{oauth_providers.filter((x) =>
user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase())
).length ? (
<Menu.Divider />
) : null}
</>
<Menu.Item closeMenuOnClick={false} icon={<PencilIcon />}>
<Select
size={useMediaQuery('(max-width: 768px)') ? 'md' : 'xs'}
data={Object.keys(themes).map((t) => ({
value: t,
label: friendlyThemeName[t],
}))}
value={systemTheme}
onChange={handleUpdateTheme}
/>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Box>
</div>
</Header>
@@ -456,8 +439,12 @@ export default function Layout({ children, props }) {
withBorder
p='md'
shadow='xs'
sx={(t) => ({
borderColor: t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.dark[0],
sx={(theme) => ({
'&[data-with-border]': {
border: `${rem(1)} solid ${
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[0]
}`,
},
})}
>
{children}
+3 -2
View File
@@ -1,5 +1,6 @@
import { NextLink } from '@mantine/next';
import { Anchor } from '@mantine/core';
import * as NextLink from 'next/link';
export default function Link(props) {
return <NextLink legacyBehavior {...props} />;
return <Anchor component={NextLink} legacyBehavior {...props} />;
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { ArrowDownRight, ArrowUpRight } from 'react-feather';
const useStyles = createStyles((theme) => ({
root: {
padding: theme.spacing.xl * 1.5,
padding: `calc(${theme.spacing.xl} * 1.5)`,
},
value: {
+18 -10
View File
@@ -12,10 +12,10 @@ import matcha_dark_azul from 'lib/themes/matcha_dark_azul';
import nord from 'lib/themes/nord';
import qogir_dark from 'lib/themes/qogir_dark';
import { createEmotionCache, MantineProvider, MantineThemeOverride } from '@mantine/core';
import { createEmotionCache, MantineProvider, MantineThemeOverride, Modal, ScrollArea } from '@mantine/core';
import { useColorScheme } from '@mantine/hooks';
import { ModalsProvider } from '@mantine/modals';
import { NotificationsProvider } from '@mantine/notifications';
import { Notifications } from '@mantine/notifications';
import { userSelector } from 'lib/recoil/user';
import { useRecoilValue } from 'recoil';
@@ -78,8 +78,9 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
components: {
AppShell: {
styles: (t) => ({
root: {
main: {
backgroundColor: t.other.AppShell_backgroundColor,
// backgroundColor: '#fff',
},
}),
},
@@ -93,8 +94,14 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
Modal: {
defaultProps: {
centered: true,
overlayBlur: 3,
overlayColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
transitionProps: {
exitDuration: 100,
},
overlayProps: {
blur: 6,
color: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
},
// scrollAreaComponent: Modal.NativeScrollArea,
},
},
Popover: {
@@ -105,8 +112,10 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
},
LoadingOverlay: {
defaultProps: {
overlayBlur: 3,
overlayColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
overlayProps: {
blur: 3,
color: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
},
},
},
Loader: {
@@ -132,9 +141,8 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
}}
>
<ModalsProvider>
<NotificationsProvider>
{props.children ? props.children : <Component {...pageProps} />}
</NotificationsProvider>
<Notifications position='top-center' style={{ marginTop: -10 }} />
{props.children ? props.children : <Component {...pageProps} />}
</ModalsProvider>
</MantineProvider>
);
+93 -18
View File
@@ -1,7 +1,21 @@
import { Box, Center, Group, Image, Text } from '@mantine/core';
import { Prism } from '@mantine/prism';
import exts from 'lib/exts';
import {
Alert,
Box,
Button,
Card,
Center,
Group,
Image,
LoadingOverlay,
Text,
UnstyledButton,
} from '@mantine/core';
import { useEffect, useState } from 'react';
import { AudioIcon, FileIcon, ImageIcon, PlayIcon } from './icons';
import KaTeX from './render/KaTeX';
import Markdown from './render/Markdown';
import PrismCode from './render/PrismCode';
function PlaceholderContent({ text, Icon }) {
return (
@@ -13,7 +27,14 @@ function PlaceholderContent({ text, Icon }) {
}
function Placeholder({ text, Icon, ...props }) {
if (props.disableResolve) props.src = null;
if (props.onClick)
return (
<UnstyledButton sx={{ height: 200 }} {...props}>
<Center sx={{ height: 200 }}>
<PlaceholderContent text={text} Icon={Icon} />
</Center>
</UnstyledButton>
);
return (
<Box sx={{ height: 200 }} {...props}>
@@ -25,45 +46,99 @@ function Placeholder({ text, Icon, ...props }) {
}
export default function Type({ file, popup = false, disableMediaPreview, ...props }) {
const type = (file.type || file.mimetype).split('/')[0];
const name = file.name || file.file;
const type =
(file.type ?? file.mimetype) === ''
? file.name.split('.').pop()
: (file.type ?? file.mimetype).split('/')[0];
const media = /^(video|audio|image|text)/.test(type);
const [text, setText] = useState('');
const shouldRenderMarkdown = file.name.endsWith('.md');
const shouldRenderTex = file.name.endsWith('.tex');
const shouldRenderCode: boolean = Object.keys(exts).includes(file.name.split('.').pop());
if (type === 'text') {
const [loading, setLoading] = useState(type === 'text' && popup);
if ((type === 'text' || shouldRenderMarkdown || shouldRenderTex || shouldRenderCode) && popup) {
useEffect(() => {
(async () => {
const res = await fetch('/r/' + name);
const res = await fetch('/r/' + file.name);
const text = await res.text();
setText(text);
setLoading(false);
})();
}, []);
}
if (media && disableMediaPreview) {
const renderAlert = () => {
return (
<Placeholder Icon={FileIcon} text={`Click to view file (${name})`} disableResolve={true} {...props} />
<Alert color='blue' variant='outline' sx={{ width: '100%' }}>
You are{props.overrideRender ? ' not ' : ' '}viewing a rendered version of the file
<Button
mx='md'
onClick={() => props.setOverrideRender(!props.overrideRender)}
compact
variant='light'
>
View {props.overrideRender ? 'rendered' : 'raw'}
</Button>
</Alert>
);
};
if ((shouldRenderMarkdown || shouldRenderTex || shouldRenderCode) && !props.overrideRender && popup)
return (
<>
{renderAlert()}
<Card p='md' my='sm'>
{shouldRenderMarkdown && <Markdown code={text} />}
{shouldRenderTex && <KaTeX code={text} />}
{shouldRenderCode && !(shouldRenderTex || shouldRenderMarkdown) && (
<PrismCode code={text} ext={type} />
)}
</Card>
</>
);
if (media && disableMediaPreview) {
return <Placeholder Icon={FileIcon} text={`Click to view file (${file.name})`} {...props} />;
}
if (file.password) {
return (
<Placeholder
Icon={FileIcon}
text={`This file is password protected. Click to view file (${file.name})`}
onClick={() => window.open(file.url)}
{...props}
/>
);
}
return popup ? (
media ? (
{
video: <video width='100%' autoPlay controls {...props} />,
video: <video width='100%' autoPlay muted controls {...props} />,
image: (
<Image
placeholder={<PlaceholderContent Icon={FileIcon} text={'Image failed to load...'} />}
{...props}
/>
),
audio: <audio autoPlay controls {...props} style={{ width: '100%' }} />,
audio: <audio autoPlay muted controls {...props} style={{ width: '100%' }} />,
text: (
<Prism withLineNumbers language={name.split('.').pop()} {...props} style={{}} sx={{}}>
{text}
</Prism>
<>
{loading ? (
<LoadingOverlay visible={loading} />
) : (
<>
{(shouldRenderMarkdown || shouldRenderTex) && renderAlert()}
<PrismCode code={text} ext={file.name.split('.').pop()} {...props} />
</>
)}
</>
),
}[type]
) : (
@@ -71,17 +146,17 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
)
) : media ? (
{
video: <Placeholder Icon={PlayIcon} text={`Click to view video (${name})`} {...props} />,
video: <Placeholder Icon={PlayIcon} text={`Click to view video (${file.name})`} {...props} />,
image: (
<Image
placeholder={<PlaceholderContent Icon={ImageIcon} text={'Image failed to load...'} />}
{...props}
/>
),
audio: <Placeholder Icon={AudioIcon} text={`Click to view audio (${name})`} {...props} />,
text: <Placeholder Icon={FileIcon} text={`Click to view text file (${name})`} {...props} />,
audio: <Placeholder Icon={AudioIcon} text={`Click to view audio (${file.name})`} {...props} />,
text: <Placeholder Icon={FileIcon} text={`Click to view text file (${file.name})`} {...props} />,
}[type]
) : (
<Placeholder Icon={FileIcon} text={`Click to view file (${name})`} {...props} />
<Placeholder Icon={FileIcon} text={`Click to view file (${file.name})`} {...props} />
);
}
+18 -11
View File
@@ -1,4 +1,4 @@
import { Group, Text, useMantineTheme } from '@mantine/core';
import { Box, Group, SimpleGrid, Text, useMantineTheme } from '@mantine/core';
import { Dropzone as MantineDropzone } from '@mantine/dropzone';
import { ImageIcon } from 'components/icons';
@@ -6,16 +6,23 @@ export default function Dropzone({ loading, onDrop, children }) {
const theme = useMantineTheme();
return (
<MantineDropzone onDrop={onDrop}>
<Group position='center' spacing='xl' style={{ minHeight: 440 }}>
<ImageIcon size={80} />
<SimpleGrid
cols={2}
breakpoints={[
{ maxWidth: 'md', cols: 1 },
{ maxWidth: 'xs', cols: 1 },
]}
>
<MantineDropzone onDrop={onDrop} styles={{ inner: { pointerEvents: 'none' } }}>
<Group position='center' spacing='xl' style={{ minHeight: 440 }}>
<ImageIcon size={80} />
<Text size='xl' inline>
Drag files here or click to select files
</Text>
</Group>
<div style={{ pointerEvents: 'all' }}>{children}</div>
</MantineDropzone>
<Text size='xl' inline>
Drag files here or click to select files
</Text>
</Group>
</MantineDropzone>
<Box>{children}</Box>
</SimpleGrid>
);
}
+25 -3
View File
@@ -1,5 +1,6 @@
import { Badge, Group, HoverCard, Table, useMantineTheme } from '@mantine/core';
import { ActionIcon, Badge, Box, Card, Group, HoverCard, Table, useMantineTheme } from '@mantine/core';
import Type from 'components/Type';
import { X } from 'react-feather';
export function FilePreview({ file }: { file: File }) {
return (
@@ -16,15 +17,36 @@ export function FilePreview({ file }: { file: File }) {
);
}
export default function FileDropzone({ file }: { file: File }) {
export default function FileDropzone({ file, onRemove }: { file: File; onRemove: () => void }) {
const theme = useMantineTheme();
return (
<HoverCard shadow='md'>
<HoverCard.Target>
<Badge size='lg'>{file.name}</Badge>
{/* <Badge size='lg'>{file.name}</Badge> */}
<Card shadow='sm' radius='sm' p='sm'>
<Group position='center' spacing='xl'>
{file.name}
</Group>
</Card>
</HoverCard.Target>
<HoverCard.Dropdown>
{/* x button that will remove file */}
<Box
sx={{
position: 'absolute',
top: 0,
right: 0,
zIndex: 1,
color: theme.colorScheme === 'dark' ? 'white' : 'white',
}}
m='xs'
>
<ActionIcon onClick={onRemove} size='sm' color='red' variant='filled'>
<X />
</ActionIcon>
</Box>
<Group grow>
<FilePreview file={file} />
+5
View File
@@ -0,0 +1,5 @@
import { Database } from 'react-feather';
export default function DatabaseIcon({ ...props }) {
return <Database size={15} {...props} />;
}
-2
View File
@@ -1,7 +1,5 @@
// https://discord.com/branding
import Image from 'next/image';
export default function DiscordIcon({ ...props }) {
return (
<svg width='24' height='24' viewBox='0 0 71 55' xmlns='http://www.w3.org/2000/svg'>
+5
View File
@@ -0,0 +1,5 @@
import { Folder } from 'react-feather';
export default function FolderIcon({ ...props }) {
return <Folder size={15} {...props} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { FolderMinus } from 'react-feather';
export default function FolderMinusIcon({ ...props }) {
return <FolderMinus size={15} {...props} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { FolderPlus } from 'react-feather';
export default function FolderPlusIcon({ ...props }) {
return <FolderPlus size={15} {...props} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { Globe } from 'react-feather';
export default function GlobeIcon({ ...props }) {
return <Globe size={15} {...props} />;
}
+1 -1
View File
@@ -2,7 +2,7 @@
import Image from 'next/image';
export default function GoogleIcon({ ...props }) {
export default function GoogleIcon({ colorScheme, ...props }) {
return (
<Image
alt='google'
+5
View File
@@ -0,0 +1,5 @@
import { HardDrive } from 'react-feather';
export default function HardDriveIcon({ ...props }) {
return <HardDrive size={15} {...props} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { Info } from 'react-feather';
export default function InfoIcon({ ...props }) {
return <Info size={15} {...props} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { Lock } from 'react-feather';
export default function LockIcon({ ...props }) {
return <Lock size={15} {...props} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { Unlock } from 'react-feather';
export default function UnlockIcon({ ...props }) {
return <Unlock size={15} {...props} />;
}
+18
View File
@@ -33,6 +33,15 @@ import GoogleIcon from './GoogleIcon';
import EyeIcon from './EyeIcon';
import RefreshIcon from './RefreshIcon';
import KeyIcon from './KeyIcon';
import DatabaseIcon from './DatabaseIcon';
import InfoIcon from './InfoIcon';
import FolderIcon from './FolderIcon';
import FolderMinusIcon from './FolderMinusIcon';
import FolderPlusIcon from './FolderPlusIcon';
import GlobeIcon from './GlobeIcon';
import LockIcon from './LockIcon';
import UnlockIcon from './UnlockIcon';
import HardDriveIcon from './HardDriveIcon';
export {
ActivityIcon,
@@ -70,4 +79,13 @@ export {
EyeIcon,
RefreshIcon,
KeyIcon,
DatabaseIcon,
InfoIcon,
FolderIcon,
FolderMinusIcon,
FolderPlusIcon,
GlobeIcon,
LockIcon,
UnlockIcon,
HardDriveIcon,
};
@@ -24,6 +24,7 @@ export default function RecentFiles({ disableMediaPreview, exifEnabled }) {
image={image}
disableMediaPreview={disableMediaPreview}
exifEnabled={exifEnabled}
refreshImages={recent.refetch}
/>
))
) : (
+8 -9
View File
@@ -1,9 +1,8 @@
import { SimpleGrid } from '@mantine/core';
import { FileIcon } from 'components/icons';
import StatCard from 'components/StatCard';
import { useStats } from 'lib/queries/stats';
import { percentChange } from 'lib/utils/client';
import { Database, Eye, Users } from 'react-feather';
import { EyeIcon, DatabaseIcon, UserIcon, FileIcon } from 'components/icons';
export function StatCards() {
const stats = useStats();
@@ -21,7 +20,7 @@ export function StatCards() {
>
<StatCard
stat={{
title: 'UPLOADED FILES',
title: 'FILES',
value: stats.isSuccess ? latest.data.count.toLocaleString() : '...',
desc: 'files have been uploaded',
icon: <FileIcon />,
@@ -34,8 +33,8 @@ export function StatCards() {
stat={{
title: 'STORAGE',
value: stats.isSuccess ? latest.data.size : '...',
desc: 'of storage used',
icon: <Database size={15} />,
desc: 'used',
icon: <DatabaseIcon />,
diff:
stats.isSuccess && before?.data
? percentChange(before.data.size_num, latest.data.size_num)
@@ -47,8 +46,8 @@ export function StatCards() {
stat={{
title: 'VIEWS',
value: stats.isSuccess ? latest.data.views_count.toLocaleString() : '...',
desc: 'total page views',
icon: <Eye size={15} />,
desc: 'total file views',
icon: <EyeIcon />,
diff:
stats.isSuccess && before?.data
? percentChange(before.data.views_count, latest.data.views_count)
@@ -60,8 +59,8 @@ export function StatCards() {
stat={{
title: 'USERS',
value: stats.isSuccess ? latest.data.count_users.toLocaleString() : '...',
desc: 'total registered users',
icon: <Users size={15} />,
desc: 'users',
icon: <UserIcon />,
}}
/>
</SimpleGrid>
+171 -87
View File
@@ -1,42 +1,88 @@
import { DataGrid, dateFilterFn, stringFilterFn } from '@dicedtomato/mantine-data-grid';
import { Title, useMantineTheme, Box } from '@mantine/core';
import { ActionIcon, Box, Group, Title, Tooltip } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { CopyIcon, CrossIcon, DeleteIcon, EnterIcon } from 'components/icons';
import FileModal from 'components/File/FileModal';
import { CopyIcon, CrossIcon, DeleteIcon, EnterIcon, FileIcon } from 'components/icons';
import Link from 'components/Link';
import MutedText from 'components/MutedText';
import useFetch from 'lib/hooks/useFetch';
import { useFiles, useRecent } from 'lib/queries/files';
import { usePaginatedFiles, useRecent } from 'lib/queries/files';
import { useStats } from 'lib/queries/stats';
import { userSelector } from 'lib/recoil/user';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import RecentFiles from './RecentFiles';
import { StatCards } from './StatCards';
export default function Dashboard({ disableMediaPreview, exifEnabled }) {
const user = useRecoilValue(userSelector);
const theme = useMantineTheme();
const images = useFiles();
const recent = useRecent('media');
const stats = useStats();
const clipboard = useClipboard();
const updateImages = () => {
images.refetch();
// pagination
const [, setNumPages] = useState(0);
const [page, setPage] = useState(1);
const [numFiles, setNumFiles] = useState(0);
useEffect(() => {
(async () => {
const { count } = await useFetch('/api/user/paged?count=true');
setNumPages(count);
const { count: filesCount } = await useFetch('/api/user/files?count=true');
setNumFiles(filesCount);
})();
}, [page]);
const files = usePaginatedFiles(page);
// sorting
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
columnAccessor: 'date',
direction: 'asc',
});
const [records, setRecords] = useState(files.data);
useEffect(() => {
setRecords(files.data);
}, [files.data]);
useEffect(() => {
if (!records || records.length === 0) return;
const sortedRecords = [...records].sort((a, b) => {
if (sortStatus.direction === 'asc') {
return a[sortStatus.columnAccessor] > b[sortStatus.columnAccessor] ? 1 : -1;
}
return a[sortStatus.columnAccessor] < b[sortStatus.columnAccessor] ? 1 : -1;
});
setRecords(sortedRecords);
}, [sortStatus]);
// file modal on click
const [open, setOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const updateFiles = () => {
files.refetch();
recent.refetch();
stats.refetch();
};
const deleteImage = async ({ original }) => {
const deleteFile = async (file) => {
const res = await useFetch('/api/user/files', 'DELETE', {
id: original.id,
id: file.id,
});
if (!res.error) {
updateImages();
updateFiles();
showNotification({
title: 'File Deleted',
message: `${original.name}`,
message: `${file.name}`,
color: 'green',
icon: <DeleteIcon />,
});
@@ -50,28 +96,47 @@ export default function Dashboard({ disableMediaPreview, exifEnabled }) {
}
};
const copyImage = async ({ original }) => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${original.url}`);
showNotification({
title: 'Copied to clipboard',
message: (
<a
href={`${window.location.protocol}//${window.location.host}${original.url}`}
>{`${window.location.protocol}//${window.location.host}${original.url}`}</a>
),
icon: <CopyIcon />,
});
const copyFile = async (file) => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${file.url}`);
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
else
showNotification({
title: 'Copied to clipboard',
message: (
<a
href={`${window.location.protocol}//${window.location.host}${file.url}`}
>{`${window.location.protocol}//${window.location.host}${file.url}`}</a>
),
icon: <CopyIcon />,
});
};
const viewImage = async ({ original }) => {
window.open(`${window.location.protocol}//${window.location.host}${original.url}`);
const viewFile = async (file) => {
window.open(`${window.location.protocol}//${window.location.host}${file.url}`);
};
return (
<div>
{selectedFile && (
<FileModal
open={open}
setOpen={setOpen}
file={selectedFile}
loading={files.isLoading}
refresh={() => files.refetch()}
reducedActions={false}
exifEnabled={exifEnabled}
/>
)}
<Title>Welcome back, {user?.username}</Title>
<MutedText size='md'>
You have <b>{images.isSuccess ? images.data.length : '...'}</b> files
You have <b>{numFiles === 0 ? '...' : numFiles}</b> files
</MutedText>
<StatCards />
@@ -83,73 +148,92 @@ export default function Dashboard({ disableMediaPreview, exifEnabled }) {
<MutedText size='md'>
View your gallery <Link href='/dashboard/files'>here</Link>.
</MutedText>
<DataGrid
data={images.data ?? []}
loading={images.isLoading}
withPagination={true}
withColumnResizing={false}
withColumnFilters={true}
noEllipsis={true}
withSorting={true}
highlightOnHover={true}
CopyIcon={CopyIcon}
DeleteIcon={DeleteIcon}
EnterIcon={EnterIcon}
deleteImage={deleteImage}
copyImage={copyImage}
viewImage={viewImage}
styles={{
dataCell: {
width: '100%',
},
td: {
':nth-child(1)': {
minWidth: 170,
},
':nth-child(2)': {
minWidth: 100,
},
},
th: {
':nth-child(1)': {
minWidth: 170,
padding: theme.spacing.lg,
borderTopLeftRadius: theme.radius.sm,
},
':nth-child(2)': {
minWidth: 100,
padding: theme.spacing.lg,
},
':nth-child(3)': {
padding: theme.spacing.lg,
},
':nth-child(4)': {
padding: theme.spacing.lg,
borderTopRightRadius: theme.radius.sm,
},
},
thead: {
backgroundColor: theme.colors.dark[6],
},
}}
empty={<></>}
<DataTable
withBorder
borderRadius='md'
highlightOnHover
verticalSpacing='sm'
columns={[
{ accessor: 'name', sortable: true },
{ accessor: 'mimetype', sortable: true },
{
accessorKey: 'file',
header: 'Name',
filterFn: stringFilterFn,
accessor: 'createdAt',
sortable: true,
render: (file) => new Date(file.createdAt).toLocaleString(),
},
{
accessorKey: 'mimetype',
header: 'Type',
filterFn: stringFilterFn,
},
{
accessorKey: 'created_at',
header: 'Date',
filterFn: dateFilterFn,
accessor: 'actions',
textAlignment: 'right',
render: (file) => (
<Group spacing={4} position='right' noWrap>
<Tooltip label='More details'>
<ActionIcon
onClick={() => {
setSelectedFile(file);
setOpen(true);
}}
color='blue'
>
<FileIcon />
</ActionIcon>
</Tooltip>
<Tooltip label='Open file in new tab'>
<ActionIcon onClick={() => viewFile(file)} color='blue'>
<EnterIcon />
</ActionIcon>
</Tooltip>
<ActionIcon onClick={() => copyFile(file)} color='green'>
<CopyIcon />
</ActionIcon>
<ActionIcon onClick={() => deleteFile(file)} color='red'>
<DeleteIcon />
</ActionIcon>
</Group>
),
},
]}
records={records ?? []}
fetching={files.isLoading}
loaderBackgroundBlur={5}
loaderVariant='dots'
minHeight={620}
page={page}
onPageChange={setPage}
recordsPerPage={16}
totalRecords={numFiles}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
rowContextMenu={{
shadow: 'xl',
borderRadius: 'md',
items: (file) => [
{
key: 'view',
icon: <EnterIcon />,
title: `View ${file.name}`,
onClick: () => viewFile(file),
},
{
key: 'copy',
icon: <CopyIcon />,
title: `Copy ${file.name}`,
onClick: () => copyFile(file),
},
{
key: 'delete',
icon: <DeleteIcon />,
title: `Delete ${file.name}`,
onClick: () => deleteFile(file),
},
],
}}
onCellClick={({ record: file }) => {
setSelectedFile(file);
setOpen(true);
}}
/>
</Box>
</div>
+49 -14
View File
@@ -1,15 +1,43 @@
import { Box, Button, Center, Checkbox, Group, Pagination, SimpleGrid, Skeleton, Title } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import File from 'components/File';
import { FileIcon } from 'components/icons';
import MutedText from 'components/MutedText';
import useFetch from 'hooks/useFetch';
import { usePaginatedFiles } from 'lib/queries/files';
import { useState } from 'react';
import { showNonMediaSelector } from 'lib/recoil/settings';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
export default function FilePagation({ disableMediaPreview, exifEnabled }) {
const [checked, setChecked] = useState(false);
export default function FilePagation({ disableMediaPreview, exifEnabled, queryPage }) {
const [checked, setChecked] = useRecoilState(showNonMediaSelector);
const [numPages, setNumPages] = useState(Number(queryPage)); // just set it to the queryPage, since the req may have not loaded yet
const [page, setPage] = useState(Number(queryPage));
const pages = usePaginatedFiles(!checked ? { filter: 'media' } : {});
const [page, setPage] = useState(1);
const isMobile = useMediaQuery('(max-width: 768px)');
const router = useRouter();
useEffect(() => {
(async () => {
router.replace(
{
query: {
...router.query,
page: page,
},
},
undefined,
{ shallow: true }
);
const { count } = await useFetch(`/api/user/paged?count=true${!checked ? '&filter=media' : ''}`);
setNumPages(count);
})();
}, [page]);
const pages = usePaginatedFiles(page, !checked ? 'media' : null);
if (pages.isSuccess && pages.data.length === 0) {
return (
@@ -40,9 +68,14 @@ export default function FilePagation({ disableMediaPreview, exifEnabled }) {
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{pages.isSuccess
? pages.data.length
? pages.data[page - 1 ?? 0].map((image) => (
? pages.data.map((image) => (
<div key={image.id}>
<File image={image} disableMediaPreview={disableMediaPreview} exifEnabled={exifEnabled} />
<File
image={image}
disableMediaPreview={disableMediaPreview}
exifEnabled={exifEnabled}
refreshImages={pages.refetch}
/>
</div>
))
: null
@@ -62,13 +95,15 @@ export default function FilePagation({ disableMediaPreview, exifEnabled }) {
paddingBottom: 3,
}}
>
<div></div>
<Pagination total={pages.data?.length ?? 0} page={page} onChange={setPage} />
<Checkbox
label='Show non-media files'
checked={checked}
onChange={(event) => setChecked(event.currentTarget.checked)}
/>
{!isMobile && <div></div>}
<Pagination total={numPages} value={page} onChange={setPage} withEdges />
{!isMobile && (
<Checkbox
label='Show non-media files'
checked={checked}
onChange={(event) => setChecked(event.currentTarget.checked)}
/>
)}
</Box>
) : null}
</>
+19 -18
View File
@@ -1,23 +1,23 @@
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Title } from '@mantine/core';
import File from 'components/File';
import { PlusIcon } from 'components/icons';
import useFetch from 'hooks/useFetch';
import { usePaginatedFiles } from 'lib/queries/files';
import Link from 'next/link';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import FilePagation from './FilePagation';
export default function Files({ disableMediaPreview, exifEnabled }) {
const pages = usePaginatedFiles({ filter: 'media' });
const favoritePages = usePaginatedFiles({ favorite: 'media' });
export default function Files({ disableMediaPreview, exifEnabled, queryPage }) {
const [favoritePage, setFavoritePage] = useState(1);
const [favoriteNumPages, setFavoriteNumPages] = useState(0);
const favoritePages = usePaginatedFiles(favoritePage, 'media', true);
const updatePages = async (favorite) => {
pages.refetch();
if (favorite) {
favoritePages.refetch();
}
};
useEffect(() => {
(async () => {
const { count } = await useFetch('/api/user/paged?count=true&filter=media&favorite=true');
setFavoriteNumPages(count);
})();
});
return (
<>
@@ -43,12 +43,13 @@ export default function Files({ disableMediaPreview, exifEnabled }) {
<Accordion.Panel>
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{favoritePages.isSuccess && favoritePages.data.length
? favoritePages.data[favoritePage - 1 ?? 0].map((image) => (
? favoritePages.data.map((image) => (
<div key={image.id}>
<File
image={image}
disableMediaPreview={disableMediaPreview}
exifEnabled={exifEnabled}
refreshImages={favoritePages.refetch}
/>
</div>
))
@@ -63,18 +64,18 @@ export default function Files({ disableMediaPreview, exifEnabled }) {
paddingBottom: 3,
}}
>
<Pagination
total={favoritePages.data.length}
page={favoritePage}
onChange={setFavoritePage}
/>
<Pagination total={favoriteNumPages} value={favoritePage} onChange={setFavoritePage} />
</Box>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
) : null}
<FilePagation disableMediaPreview={disableMediaPreview} exifEnabled={exifEnabled} />
<FilePagation
disableMediaPreview={disableMediaPreview}
exifEnabled={exifEnabled}
queryPage={queryPage}
/>
</>
);
}
@@ -0,0 +1,66 @@
import { Button, Group, Modal, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { showNotification } from '@mantine/notifications';
import { CrossIcon, FolderIcon } from 'components/icons';
import MutedText from 'components/MutedText';
import useFetch from 'hooks/useFetch';
import { useRouter } from 'next/router';
export default function CreateFolderModal({ open, setOpen, updateFolders, createWithFile }) {
const router = useRouter();
const form = useForm({
initialValues: {
name: '',
},
});
const onSubmit = async (values) => {
const res = await useFetch('/api/user/folders', 'POST', {
name: values.name,
add: createWithFile ? [createWithFile] : undefined,
});
if (res.error) {
showNotification({
title: 'Failed to create folder',
message: res.error,
icon: <CrossIcon />,
color: 'red',
});
} else {
showNotification({
title: 'Created folder ' + res.name,
message: createWithFile ? 'Added file to folder' : undefined,
icon: <FolderIcon />,
color: 'green',
});
if (createWithFile) {
router.push('/dashboard/folders');
}
}
setOpen(false);
updateFolders();
};
return (
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>Create Folder</Title>}>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput label='Folder Name' placeholder='Folder Name' {...form.getInputProps('name')} />
{createWithFile && (
<MutedText size='sm'>
Creating this folder will add file with an ID of <b>{createWithFile}</b> to it automatically.
</MutedText>
)}
<Group position='right' mt='md'>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button type='submit'>Create</Button>
</Group>
</form>
</Modal>
);
}
@@ -0,0 +1,46 @@
import { Center, Modal, SimpleGrid, Stack, Text, Title } from '@mantine/core';
import File from 'components/File';
import MutedText from 'components/MutedText';
import { useFolder } from 'lib/queries/folders';
export default function ViewFolderFilesModal({ open, setOpen, folderId, disableMediaPreview, exifEnabled }) {
if (!folderId) return null;
const folder = useFolder(folderId, true);
return (
<Modal
opened={open}
onClose={() => setOpen(false)}
title={<Title>View {folder.data?.name}&apos;s files</Title>}
size='xl'
>
{folder.isSuccess ? (
<>
{folder.data.files.length ? (
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{folder.data.files.map((file) => (
<File
disableMediaPreview={disableMediaPreview}
key={file.id}
image={file}
exifEnabled={exifEnabled}
refreshImages={folder.refetch}
/>
))}
</SimpleGrid>
) : (
<Center>
<Stack>
<Text align='center'>No files in this folder</Text>
<MutedText size='sm'>
Add files to {folder.data.name} by clicking a file in the Files tab and selecting a folder
</MutedText>
</Stack>
</Center>
)}
</>
) : null}
</Modal>
);
}
+205
View File
@@ -0,0 +1,205 @@
import { ActionIcon, Avatar, Card, Group, SimpleGrid, Skeleton, Stack, Title, Tooltip } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { DeleteIcon, FileIcon, PlusIcon, LockIcon, UnlockIcon, LinkIcon, CopyIcon } from 'components/icons';
import Link from 'components/Link';
import MutedText from 'components/MutedText';
import useFetch from 'hooks/useFetch';
import { useFolders } from 'lib/queries/folders';
import { relativeTime } from 'lib/utils/client';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import CreateFolderModal from './CreateFolderModal';
import ViewFolderFilesModal from './ViewFolderFilesModal';
export default function Folders({ disableMediaPreview, exifEnabled }) {
const folders = useFolders();
const [createOpen, setCreateOpen] = useState(false);
const [createWithFile, setCreateWithFile] = useState(null);
const [viewOpen, setViewOpen] = useState(false);
const [activeFolderId, setActiveFolderId] = useState(null);
const modals = useModals();
const clipboard = useClipboard();
const router = useRouter();
useEffect(() => {
if (router.query.create) {
setCreateOpen(true);
setCreateWithFile(router.query.create);
}
}, []);
const deleteFolder = (folder) => {
modals.openConfirmModal({
title: <Title>Delete folder {folder.name}?</Title>,
children:
'Are you sure you want to delete this folder? All files within the folder will still exist, but will no longer be in a folder.',
labels: {
confirm: 'Delete',
cancel: 'Cancel',
},
onConfirm: async () => {
const res = await useFetch(`/api/user/folders/${folder.id}`, 'DELETE', {
deleteFolder: true,
});
if (!res.error) {
showNotification({
title: 'Deleted folder',
message: `Deleted folder ${folder.name}`,
color: 'green',
icon: <DeleteIcon />,
});
folders.refetch();
} else {
showNotification({
title: 'Failed to delete folder',
message: res.error,
color: 'red',
icon: <DeleteIcon />,
});
folders.refetch();
}
},
});
};
const makePublic = async (folder) => {
const res = await useFetch(`/api/user/folders/${folder.id}`, 'PATCH', {
public: folder.public ? false : true,
});
if (!res.error) {
showNotification({
title: 'Made folder public',
message: `Made folder ${folder.name} ${folder.public ? 'private' : 'public'}`,
color: 'green',
icon: <UnlockIcon />,
});
folders.refetch();
} else {
showNotification({
title: 'Failed to make folder public/private',
message: res.error,
color: 'red',
icon: <UnlockIcon />,
});
folders.refetch();
}
};
return (
<>
<CreateFolderModal
open={createOpen}
setOpen={setCreateOpen}
createWithFile={createWithFile}
updateFolders={folders.refetch}
/>
<ViewFolderFilesModal
open={viewOpen}
setOpen={setViewOpen}
folderId={activeFolderId}
disableMediaPreview={disableMediaPreview}
exifEnabled={exifEnabled}
/>
<Group mb='md'>
<Title>Folders</Title>
<ActionIcon onClick={() => setCreateOpen(!createOpen)} component='a' variant='filled' color='primary'>
<PlusIcon />
</ActionIcon>
</Group>
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{folders.isSuccess
? folders.data.length
? folders.data.map((folder) => (
<Card key={folder.id}>
<Group position='apart'>
<Group position='left'>
<Avatar size='lg' color='primary'>
{folder.id}
</Avatar>
<Stack spacing={0}>
<Title>{folder.name}</Title>
<MutedText size='sm'>ID: {folder.id}</MutedText>
<MutedText size='sm'>Public: {folder.public ? 'Yes' : 'No'}</MutedText>
<Tooltip label={new Date(folder.createdAt).toLocaleString()}>
<div>
<MutedText size='sm'>
Created {relativeTime(new Date(folder.createdAt))}
</MutedText>
</div>
</Tooltip>
<Tooltip label={new Date(folder.updatedAt).toLocaleString()}>
<div>
<MutedText size='sm'>
Last updated {relativeTime(new Date(folder.updatedAt))}
</MutedText>
</div>
</Tooltip>
</Stack>
</Group>
<Group>
<Tooltip label={folder.public ? 'Make folder private' : 'Make folder public'}>
<ActionIcon
aria-label={folder.public ? 'make private' : 'make public'}
onClick={() => makePublic(folder)}
>
{folder.public ? <LockIcon /> : <UnlockIcon />}
</ActionIcon>
</Tooltip>
<ActionIcon
aria-label='view files'
onClick={() => {
setViewOpen(!viewOpen);
setActiveFolderId(folder.id);
}}
>
<FileIcon />
</ActionIcon>
<ActionIcon
aria-label='copy link'
onClick={() => {
clipboard.copy(`${window.location.origin}/folder/${folder.id}`);
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
else
showNotification({
title: 'Copied folder link',
message: (
<>
Copied <Link href={`/folder/${folder.id}`}>folder link</Link> to clipboard
</>
),
color: 'green',
icon: <CopyIcon />,
});
}}
>
<LinkIcon />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => deleteFolder(folder)}>
<DeleteIcon />
</ActionIcon>
</Group>
</Group>
</Card>
))
: null
: [1, 2, 3, 4].map((x) => (
<div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
</div>
))}
</SimpleGrid>
</>
);
}
+22 -15
View File
@@ -38,7 +38,7 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
if (!expires.includes(values.expires)) return form.setFieldError('expires', 'Invalid expiration');
if (values.count < 1 || values.count > 100)
return form.setFieldError('count', 'Must be between 1 and 100');
const expires_at =
const expiresAt =
values.expires === 'never'
? null
: new Date(
@@ -57,7 +57,7 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
setOpen(false);
const res = await useFetch('/api/auth/invite', 'POST', {
expires_at,
expiresAt,
count: values.count,
});
@@ -108,7 +108,7 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
min={1}
stepHoldDelay={200}
stepHoldInterval={100}
parser={(v: string) => Number(v.replace(/[^\d]/g, ''))}
parser={(v: string) => v.replace(/[^\d]/g, '')}
/>
<Group position='right' mt='md'>
@@ -132,7 +132,7 @@ export default function Invites() {
modals.openConfirmModal({
title: `Delete ${invite.code}?`,
centered: true,
overlayBlur: 3,
overlayProps: { blur: 3 },
labels: { confirm: 'Yes', cancel: 'No' },
onConfirm: async () => {
const res = await useFetch(`/api/auth/invite?code=${invite.code}`, 'DELETE');
@@ -158,11 +158,18 @@ export default function Invites() {
const handleCopy = async (invite) => {
clipboard.copy(`${window.location.protocol}//${window.location.host}/auth/register?code=${invite.code}`);
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
else
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
};
const updateInvites = async () => {
@@ -201,26 +208,26 @@ export default function Invites() {
{invite.code}
{invite.used && <> (Used)</>}
</Title>
<Tooltip label={new Date(invite.created_at).toLocaleString()}>
<Tooltip label={new Date(invite.createdAt).toLocaleString()}>
<div>
<MutedText size='sm'>Created {relativeTime(new Date(invite.created_at))}</MutedText>
<MutedText size='sm'>Created {relativeTime(new Date(invite.createdAt))}</MutedText>
</div>
</Tooltip>
<Tooltip label={new Date(invite.expires_at).toLocaleString()}>
<Tooltip label={new Date(invite.expiresAt).toLocaleString()}>
<div>
<MutedText size='sm'>{expireText(invite.expires_at)}</MutedText>
<MutedText size='sm'>{expireText(invite.expiresAt)}</MutedText>
</div>
</Tooltip>
</Stack>
</Group>
<Group position='right'>
<Stack>
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
<CopyIcon />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
<DeleteIcon />
</ActionIcon>
</Group>
</Stack>
</Group>
</Card>
))
@@ -0,0 +1,88 @@
import { Button, Checkbox, Group, Modal, Text, Title } from '@mantine/core';
import { closeAllModals, openConfirmModal } from '@mantine/modals';
import { showNotification, updateNotification } from '@mantine/notifications';
import { CheckIcon, CrossIcon } from 'components/icons';
import useFetch from 'hooks/useFetch';
export default function ClearStorage({ open, setOpen, check, setCheck }) {
const handleDelete = async (datasource: boolean, orphaned?: boolean) => {
showNotification({
id: 'clear-uploads',
title: 'Clearing...',
message: '',
loading: true,
autoClose: false,
});
const res = await useFetch('/api/admin/clear', 'POST', { datasource, orphaned });
if (res.error) {
updateNotification({
id: 'clear-uploads',
title: 'Error while clearing uploads',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
} else {
updateNotification({
id: 'clear-uploads',
title: 'Successfully cleared uploads',
message: '',
color: 'green',
icon: <CheckIcon />,
});
}
};
return (
<Modal
opened={open}
onClose={() => setOpen(false)}
title={<Title size='sm'>Are you sure you want to clear all uploads in the database?</Title>}
>
<Checkbox
id='orphanedFiles'
label='Clear only orphaned files?'
description='Orphaned files are not owned by anyone. They can&#39;t be seen the dashboard by anyone.'
checked={check}
onChange={(e) => setCheck(e.currentTarget.checked)}
/>
<Group position='right' mt='md'>
<Button
onClick={() => {
setOpen(() => false);
}}
>
No
</Button>
<Button
onClick={() => {
setOpen(false);
openConfirmModal({
title: 'Do you want to clear storage too?',
labels: { confirm: 'Yes', cancel: check ? 'Ok' : 'No' },
children: check && (
<Text size='sm' color='gray'>
Due to clearing orphaned files, storage clearing will be unavailable.
</Text>
),
confirmProps: { disabled: check },
onConfirm: () => {
closeAllModals();
handleDelete(true);
},
onCancel: () => {
closeAllModals();
handleDelete(false, check);
},
onClose: () => setCheck(false),
});
}}
>
Yes
</Button>
</Group>
</Modal>
);
}
+63 -12
View File
@@ -1,3 +1,5 @@
import { Code } from '@mantine/core';
import Link from 'components/Link';
import { GeneratorModal } from './GeneratorModal';
export default function Flameshot({ user, open, setOpen }) {
@@ -5,28 +7,31 @@ export default function Flameshot({ user, open, setOpen }) {
const curl = [
'curl',
'-H',
'"Content-Type: multipart/form-data"',
'-H',
`"authorization: ${user?.token}"`,
'-F',
'file=@/tmp/ss.png',
`${
window.location.protocol +
'//' +
window.location.hostname +
(window.location.port ? ':' + window.location.port : '')
}/api/upload`,
}/api/${values.type === 'upload-file' ? 'upload' : 'shorten'}`,
];
if (values.type === 'upload-file') {
curl.push('-F', 'file=@/tmp/ss.png');
curl.push('-H', '"Content-Type: multipart/form-data"');
} else {
curl.push('-H', '"Content-Type: application/json"');
}
const extraHeaders = {};
if (values.format !== 'RANDOM') {
if (values.format !== 'RANDOM' && values.type === 'upload-file') {
extraHeaders['Format'] = values.format;
} else {
delete extraHeaders['Format'];
}
if (values.imageCompression !== 0) {
if (values.imageCompression !== 0 && values.type === 'upload-file') {
extraHeaders['Image-Compression-Percent'] = values.imageCompression;
} else {
delete extraHeaders['Image-Compression-Percent'];
@@ -38,25 +43,55 @@ export default function Flameshot({ user, open, setOpen }) {
delete extraHeaders['Zws'];
}
if (values.embed) {
if (values.embed && values.type === 'upload-file') {
extraHeaders['Embed'] = 'true';
} else {
delete extraHeaders['Embed'];
}
if (values.noJSON) {
extraHeaders['No-JSON'] = 'true';
} else {
delete extraHeaders['No-JSON'];
}
if (values.originalName && values.type === 'upload-file') {
extraHeaders['Original-Name'] = 'true';
} else {
delete extraHeaders['Original-Name'];
}
if (values.overrideDomain && values.overrideDomain.trim() !== '') {
extraHeaders['Override-Domain'] = values.overrideDomain;
} else {
delete extraHeaders['Override-Domain'];
}
for (const [key, value] of Object.entries(extraHeaders)) {
curl.push('-H');
curl.push(`"${key}: ${value}"`);
}
const shell = `#!/bin/bash
let shell;
if (values.type === 'upload-file') {
shell = `#!/bin/bash${values.wlCompositorNotSupported ? '\nexport XDG_CURRENT_DESKTOP=sway\n' : ''}
flameshot gui -r > /tmp/ss.png;
${curl.join(' ')} | jq -r '.files[0]' | tr -d '\n' | xsel -ib;
${curl.join(' ')}${values.noJSON ? '' : " | jq -r '.files[0]'"} | tr -d '\\n' | ${
values.wlCompatibility ? 'wl-copy' : 'xsel -ib'
};
`;
} else if (values.type === 'shorten-url') {
shell = `#!/bin/bash
arg=$1;
${curl.join(' ')} -d "{\\"url\\": \\"$arg\\"}"${values.noJSON ? '' : " | jq -r '.url'"} | tr -d '\\n' | ${
values.wlCompatibility ? 'wl-copy' : 'xsel -ib'
};
`;
}
const pseudoElement = document.createElement('a');
pseudoElement.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(shell));
pseudoElement.setAttribute('download', 'zipline.sh');
pseudoElement.setAttribute('download', `zipline${values.type === 'upload-file' ? '' : '-url'}.sh`);
pseudoElement.style.display = 'none';
document.body.appendChild(pseudoElement);
pseudoElement.click();
@@ -68,7 +103,23 @@ ${curl.join(' ')} | jq -r '.files[0]' | tr -d '\n' | xsel -ib;
opened={open}
onClose={() => setOpen(false)}
title='Flameshot'
desc='To use this script, you need Flameshot, curl, jq, and xsel installed. This script is intended for use on Linux only.'
desc={
<>
To use this script, you need <Link href='https://flameshot.org'>Flameshot</Link>,{' '}
<Link href='https://curl.se/'>
<Code>curl</Code>
</Link>
,{' '}
<Link href='https://github.com/stedolan/jq'>
<Code>jq</Code>
</Link>
, and{' '}
<Link href='https://github.com/kfish/xsel'>
<Code>xsel</Code>
</Link>{' '}
installed. This script is intended for use on Linux only.
</>
}
onSubmit={onSubmit}
/>
);
+174 -14
View File
@@ -1,21 +1,105 @@
import { Button, Checkbox, Group, Modal, NumberInput, Select, Text, Title } from '@mantine/core';
import {
Box,
Button,
Checkbox,
Code,
Group,
Modal,
NumberInput,
Select,
Stack,
Switch,
Text,
TextInput,
Title,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { DownloadIcon } from 'components/icons';
import { DownloadIcon, GlobeIcon } from 'components/icons';
import Link from 'components/Link';
import MutedText from 'components/MutedText';
import { useReducer, useState } from 'react';
const DEFAULT_OD_DESC = 'Override the default domain(s). Type in a URL, e.g https://example.com';
export function GeneratorModal({ opened, onClose, title, onSubmit, ...other }) {
const form = useForm({
initialValues: {
type: 'upload-file',
format: 'RANDOM',
imageCompression: 0,
zeroWidthSpace: false,
embed: false,
wlCompatibility: false,
wlCompositorNotSupported: false,
noJSON: false,
originalName: false,
overrideDomain: null,
},
});
const [isUploadFile, setIsUploadFile] = useState(true);
const onChangeType = (value) => {
setIsUploadFile(value === 'upload-file');
form.setFieldValue('type', value);
};
const [odState, setODState] = useReducer((state, newState) => ({ ...state, ...newState }), {
description: DEFAULT_OD_DESC,
error: '',
domain: '',
});
const handleOD = (e) => {
setODState({ error: '' });
if (e.currentTarget.value === '') {
setODState({ description: DEFAULT_OD_DESC, error: '', domain: null });
form.setFieldValue('overrideDomain', null);
return;
}
try {
const url = new URL(e.currentTarget.value);
setODState({
description: (
<>
{DEFAULT_OD_DESC}
<br />
<br />
Using domain &quot;<b>{url.hostname}</b>&quot;
</>
),
error: '',
domain: url.hostname,
});
form.setFieldValue('overrideDomain', url.hostname);
} catch (e) {
setODState({ error: 'Invalid URL', domain: '' });
form.setFieldValue('overrideDomain', null);
}
};
return (
<Modal opened={opened} onClose={onClose} title={<Title order={3}>{title}</Title>} size='lg'>
{other.desc && <Text mb='md'>{other.desc}</Text>}
{other.desc && (
<MutedText size='md' mb='md'>
{other.desc}
</MutedText>
)}
<form onSubmit={form.onSubmit((values) => onSubmit(values))}>
<Select
label='Type'
data={[
{ value: 'upload-file', label: 'Upload file' },
{ value: 'shorten-url', label: 'Shorten URLs' },
]}
id='type'
my='sm'
{...form.getInputProps('type')}
onChange={onChangeType}
/>
<Select
label='Select file name format'
data={[
@@ -25,33 +109,109 @@ export function GeneratorModal({ opened, onClose, title, onSubmit, ...other }) {
{ value: 'NAME', label: 'Name (keeps original file name)' },
]}
id='format'
my='sm'
disabled={!isUploadFile}
{...form.getInputProps('format')}
/>
<NumberInput
label={"Image Compression (leave at 0 if you don't want to compress)"}
label='Image Compression'
description='Set the image compression level (0-100). 0 is no compression, 100 is maximum compression.'
max={100}
min={0}
mt='md'
my='sm'
id='imageCompression'
disabled={!isUploadFile}
{...form.getInputProps('imageCompression')}
/>
<Group grow mt='md'>
<Checkbox
<TextInput
label='Override Domain'
onChange={handleOD}
icon={<GlobeIcon />}
description={odState.description}
error={odState.error}
/>
<Stack my='md'>
<Switch
label='Zero Width Space'
description='Use zero width spaces as the file name'
id='zeroWidthSpace'
{...form.getInputProps('zeroWidthSpace', { type: 'checkbox' })}
/>
<Checkbox label='Embed' id='embed' {...form.getInputProps('embed', { type: 'checkbox' })} />
</Group>
<Switch
description='Return response as plain text instead of JSON'
label='No JSON'
id='noJSON'
{...form.getInputProps('noJSON', { type: 'checkbox' })}
/>
<Switch
description='Image will display with embedded metadata'
label='Embed'
id='embed'
disabled={!isUploadFile}
{...form.getInputProps('embed', { type: 'checkbox' })}
/>
<Switch
description='Whether or not to show the original name when downloading this specific file. This will not change the name format in the URL.'
label='Original Name'
id='originalName'
disabled={!isUploadFile}
{...form.getInputProps('originalName', { type: 'checkbox' })}
/>
</Stack>
<Group grow>
<Button mt='md' onClick={form.reset}>
Reset
</Button>
{title === 'Flameshot' && (
<>
<Box my='md'>
<Text>Wayland</Text>
<MutedText size='sm'>
If using wayland, you can check the boxes below to your liking. This will require{' '}
<Link href='https://github.com/bugaevc/wl-clipboard'>
<Code>wl-clipboard</Code>
</Link>{' '}
for the <Code>wl-copy</Code> command.
</MutedText>
</Box>
<Button mt='md' rightIcon={<DownloadIcon />} type='submit'>
<Group my='md'>
<Checkbox
label='Enable Wayland Compatibility'
description={
<>
Use <Code>wl-copy</Code> instead of <Code>xsel -ib</Code>
</>
}
id='wlCompatibility'
{...form.getInputProps('wlCompatibility', { type: 'checkbox' })}
/>
<Checkbox
label={
<>
Using a DE/compositor that <b>isn&apos;t</b> GNOME, KDE or Sway
</>
}
description={
<>
If using a compositor such as{' '}
<Link href='https://github.com/hyprwm/hyprland'>Hyprland</Link>, this option will set the{' '}
<Code>XDG_CURRENT_DESKTOP=sway</Code> to workaround Flameshot&apos;s errors
</>
}
disabled={!isUploadFile}
id='wlCompositorNotSupported'
{...form.getInputProps('wlCompositorNotSupported', { type: 'checkbox' })}
/>
</Group>
</>
)}
<Group grow my='md'>
<Button onClick={form.reset}>Reset</Button>
<Button rightIcon={<DownloadIcon />} type='submit'>
Download
</Button>
</Group>
+65 -34
View File
@@ -1,57 +1,88 @@
import { useState } from 'react';
import { useReducer, useState } from 'react';
import { GeneratorModal } from './GeneratorModal';
export default function ShareX({ user, open, setOpen }) {
const [config, setConfig] = useState({
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,
},
URL: '$json:files[0]$',
Body: 'MultipartFormData',
FileFormName: 'file',
});
const onSubmit = (values) => {
if (values.format !== 'RANDOM') {
config.Headers['Format'] = values.format;
setConfig(config);
const hostname = window.location.hostname;
const config = {
Version: '14.1.0',
Name: `Zipline - ${hostname} - ${values.type === 'upload-file' ? 'File' : 'URL'}`,
DestinationType: 'ImageUploader, TextUploader, FileUploader',
RequestMethod: 'POST',
RequestURL: `${
window.location.protocol +
'//' +
window.location.hostname +
(window.location.port ? ':' + window.location.port : '')
}/api/upload`,
Headers: {
Authorization: user?.token,
},
URL: '{json:files[0]}',
Body: 'MultipartFormData',
FileFormName: 'file',
Data: undefined,
};
if (values.type === 'shorten-url') {
config.RequestURL = `${
window.location.protocol +
'//' +
window.location.hostname +
(window.location.port ? ':' + window.location.port : '')
}/api/shorten`;
config.URL = '{json:url}';
config.Body = 'JSON';
config.DestinationType = 'URLShortener,URLSharingService';
delete config.FileFormName;
config.Data = JSON.stringify({ url: '{input}' });
} else {
delete config.Headers['Format'];
setConfig(config);
delete config.Data;
}
if (values.imageCompression !== 0) {
if (values.format !== 'RANDOM' && values.type === 'upload-file') {
config.Headers['Format'] = values.format;
} else {
delete config.Headers['Format'];
}
if (values.imageCompression !== 0 && values.type === 'upload-file') {
config.Headers['Image-Compression-Percent'] = values.imageCompression;
setConfig(config);
} else {
delete config.Headers['Image-Compression-Percent'];
setConfig(config);
}
if (values.zeroWidthSpace) {
config.Headers['Zws'] = 'true';
setConfig(config);
} else {
delete config.Headers['Zws'];
setConfig(config);
}
if (values.embed) {
if (values.embed && values.type === 'upload-file') {
config.Headers['Embed'] = 'true';
setConfig(config);
} else {
delete config.Headers['Embed'];
setConfig(config);
}
if (values.noJSON) {
config.URL = '{response}';
config.Headers['No-JSON'] = 'true';
} else {
delete config.Headers['No-JSON'];
config.URL = values.type === 'upload-file' ? '{json:files[0]}' : '{json:url}';
}
if (values.originalName && values.type === 'upload-file') {
config.Headers['Original-Name'] = 'true';
} else {
delete config.Headers['Original-Name'];
}
if (values.overrideDomain && values.overrideDomain.trim() !== '') {
config.Headers['Override-Domain'] = values.overrideDomain;
} else {
delete config.Headers['Override-Domain'];
}
const pseudoElement = document.createElement('a');
@@ -59,7 +90,7 @@ export default function ShareX({ user, open, setOpen }) {
'href',
'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(config, null, '\t'))
);
pseudoElement.setAttribute('download', 'zipline.sxcu');
pseudoElement.setAttribute('download', `zipline${values.type === 'upload-file' ? '' : '-url'}.sxcu`);
pseudoElement.style.display = 'none';
document.body.appendChild(pseudoElement);
pseudoElement.click();
+44 -30
View File
@@ -1,5 +1,6 @@
import { Button, Center, Image, Modal, NumberInput, Text, Title } from '@mantine/core';
import { Button, Center, Image, Modal, NumberInput, PinInput, Text, Title } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { useForm } from '@mantine/form';
import { CheckIcon, CrossIcon } from 'components/icons';
import useFetch from 'hooks/useFetch';
import { useEffect, useState } from 'react';
@@ -8,7 +9,6 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
const [secret, setSecret] = useState('');
const [qrCode, setQrCode] = useState('');
const [disabled, setDisabled] = useState(false);
const [code, setCode] = useState(undefined);
const [error, setError] = useState('');
useEffect(() => {
@@ -32,15 +32,15 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
})();
}, [opened]);
const disableTotp = async () => {
const disableTotp = async (code) => {
setDisabled(true);
const str = code.toString();
if (str.length !== 6) {
if (code.length !== 6) {
setDisabled(false);
return setError('Code must be 6 digits');
}
const resp = await useFetch('/api/user/mfa/totp', 'DELETE', {
code: str,
code,
});
if (resp.error) {
@@ -61,16 +61,16 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
setDisabled(false);
};
const verifyCode = async () => {
const verifyCode = async (code) => {
setDisabled(true);
const str = code.toString();
if (str.length !== 6) {
if (code.length !== 6) {
setDisabled(false);
return setError('Code must be 6 digits');
}
const resp = await useFetch('/api/user/mfa/totp', 'POST', {
secret,
code: str,
code,
register: true,
});
@@ -92,6 +92,13 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
setDisabled(false);
};
const handlePinChange = (value) => {
if (value.length === 6) {
setDisabled(true);
deleteTotp ? disableTotp(value) : verifyCode(value);
}
};
return (
<Modal
opened={opened}
@@ -110,30 +117,37 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
<Center>
<Image height={180} width={180} src={qrCode} alt='QR Code' withPlaceholder />
</Center>
<Text my='sm'>QR Code not working? Try manually entering the code into your app: {secret}</Text>
</>
)}
<NumberInput
placeholder='2FA Code'
label='Verify'
size='xl'
hideControls
maxLength={6}
minLength={6}
value={code}
onChange={(e) => setCode(e)}
error={error}
/>
<Center my='md'>
<PinInput
data-autofocus
length={6}
oneTimeCode
type='number'
placeholder=''
onChange={handlePinChange}
autoFocus={true}
error={!!error}
disabled={disabled}
size='xl'
/>
</Center>
<Button
disabled={disabled}
size='lg'
fullWidth
mt='md'
rightIcon={<CheckIcon />}
onClick={deleteTotp ? disableTotp : verifyCode}
>
{error && (
<Text my='sm' size='sm' color='red' align='center'>
{error}
</Text>
)}
{!deleteTotp && (
<Text my='sm' size='sm' color='gray' align='center'>
QR Code not working? Try manually entering the code into your app: {secret}
</Text>
)}
<Button disabled={disabled} size='lg' fullWidth mt='md' rightIcon={<CheckIcon />} type='submit'>
Verify{deleteTotp ? ' and Disable' : ''}
</Button>
</Modal>
+95 -33
View File
@@ -8,6 +8,7 @@ import {
Group,
Image,
PasswordInput,
SimpleGrid,
Space,
Text,
TextInput,
@@ -15,7 +16,7 @@ import {
Tooltip,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { randomId, useInterval } from '@mantine/hooks';
import { randomId, useInterval, useMediaQuery } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { showNotification, updateNotification } from '@mantine/notifications';
import {
@@ -41,6 +42,7 @@ import { bytesToHuman } from 'lib/utils/bytes';
import { capitalize } from 'lib/utils/client';
import { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
import ClearStorage from './ClearStorage';
import Flameshot from './Flameshot';
import ShareX from './ShareX';
import { TotpModal } from './TotpModal';
@@ -75,10 +77,12 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
const [totpOpen, setTotpOpen] = useState(false);
const [shareXOpen, setShareXOpen] = useState(false);
const [flameshotOpen, setFlameshotOpen] = useState(false);
const [clrStorOpen, setClrStorOpen] = useState(false);
const [exports, setExports] = useState([]);
const [file, setFile] = useState<File>(null);
const [fileDataURL, setFileDataURL] = useState(user.avatar ?? null);
const [totpEnabled, setTotpEnabled] = useState(!!user.totpSecret);
const [checked, setCheck] = useState(false);
const getDataURL = (f: File): Promise<string> => {
return new Promise((res, rej) => {
@@ -139,9 +143,10 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
initialValues: {
username: user.username,
password: '',
embedTitle: user.embedTitle ?? '',
embedColor: user.embedColor,
embedSiteName: user.embedSiteName ?? '',
embedTitle: user.embed?.title ?? null,
embedColor: user.embed?.color ?? '',
embedSiteName: user.embed?.siteName ?? null,
embedDescription: user.embed?.description ?? null,
domains: user.domains.join(','),
},
});
@@ -149,9 +154,12 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
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 cleanEmbed = {
title: values.embedTitle ? values.embedTitle.trim() : null,
color: values.embedColor !== '' ? values.embedColor.trim() : null,
siteName: values.embedSiteName ? values.embedSiteName.trim() : null,
description: values.embedDescription ? values.embedDescription.trim() : null,
};
if (cleanUsername === '') return form.setFieldError('username', "Username can't be nothing");
@@ -166,13 +174,11 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
const data = {
username: cleanUsername,
password: cleanPassword === '' ? null : cleanPassword,
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor,
embedSiteName: cleanEmbedSiteName === '' ? null : cleanEmbedSiteName,
domains: values.domains
.split(/\s?,\s?/)
.map((x) => x.trim())
.filter((x) => x !== ''),
embed: cleanEmbed,
};
const newUser = await useFetch('/api/user', 'PATCH', data);
@@ -353,14 +359,31 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
my='sm'
{...form.getInputProps('password')}
/>
<TextInput id='embedTitle' label='Embed Title' my='sm' {...form.getInputProps('embedTitle')} />
<ColorInput id='embedColor' label='Embed Color' my='sm' {...form.getInputProps('embedColor')} />
<TextInput
id='embedSiteName'
label='Embed Site Name'
my='sm'
{...form.getInputProps('embedSiteName')}
/>
<SimpleGrid
cols={4}
breakpoints={[
{ maxWidth: 768, cols: 1 },
{ minWidth: 769, maxWidth: 1024, cols: 2 },
{ minWidth: 1281, cols: 4 },
]}
>
<TextInput id='embedTitle' label='Embed Title' my='sm' {...form.getInputProps('embedTitle')} />
<ColorInput id='embedColor' label='Embed Color' my='sm' {...form.getInputProps('embedColor')} />
<TextInput
id='embedSiteName'
label='Embed Site Name'
my='sm'
{...form.getInputProps('embedSiteName')}
/>
<TextInput
id='embedDescription'
label='Embed Description'
my='sm'
{...form.getInputProps('embedDescription')}
/>
</SimpleGrid>
<TextInput
id='domains'
label='Domains'
@@ -371,7 +394,18 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
/>
<Group position='right' mt='md'>
<Button type='submit'>Save User</Button>
<Button
type='submit'
size='lg'
my='sm'
sx={{
'@media screen and (max-width: 768px)': {
width: '100%',
},
}}
>
Save
</Button>
</Group>
</form>
@@ -379,12 +413,21 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
<Box my='md'>
<Title>Two Factor Authentication</Title>
<MutedText size='md'>
{user.totpSecret
{totpEnabled
? 'You have two factor authentication enabled.'
: 'You do not have two factor authentication enabled.'}
</MutedText>
<Button size='lg' my='sm' onClick={() => setTotpOpen(true)}>
<Button
size='lg'
my='sm'
onClick={() => setTotpOpen(true)}
sx={{
'@media screen and (max-width: 768px)': {
width: '100%',
},
}}
>
{totpEnabled ? 'Disable' : 'Enable'} Two Factor Authentication
</Button>
@@ -446,20 +489,17 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
<Text>Preview:</Text>
<Button
leftIcon={fileDataURL ? <Image src={fileDataURL} height={32} radius='md' /> : <SettingsIcon />}
sx={(t) => ({
backgroundColor: '#00000000',
'&:hover': {
backgroundColor: t.other.hover,
},
})}
size='xl'
p='sm'
variant='subtle'
color='gray'
compact
>
{user.username}
</Button>
</Card>
<Group position='right' mt='md'>
<Group position='right' my='md' grow={useMediaQuery('(max-width: 768px)')}>
<Button
onClick={() => {
setFile(null);
@@ -473,12 +513,12 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
</Group>
</Box>
<Box mb='md'>
<Box my='md'>
<Title>Manage Data</Title>
<MutedText size='md'>Delete, or export your data into a zip file.</MutedText>
</Box>
<Group>
<Group my='md' grow={useMediaQuery('(max-width: 768px)')}>
<Button onClick={openDeleteModal} rightIcon={<DeleteIcon />} color='red'>
Delete All Data
</Button>
@@ -521,26 +561,48 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
{user.administrator && (
<Box mt='md'>
<Title>Server</Title>
<Group>
<Group my='md' grow={useMediaQuery('(max-width: 768px)')}>
<Button size='md' onClick={forceUpdateStats} color='red' rightIcon={<RefreshIcon />}>
Force Update Stats
</Button>
<Button size='md' onClick={() => setClrStorOpen(true)} color='red' rightIcon={<TrashIcon />}>
Delete all uploads
</Button>
</Group>
</Box>
)}
<Title my='md'>Uploaders</Title>
<Group>
<Button size='xl' onClick={() => setShareXOpen(true)} rightIcon={<ShareXIcon />}>
<Button
size='xl'
onClick={() => setShareXOpen(true)}
rightIcon={<ShareXIcon />}
sx={{
'@media screen and (max-width: 768px)': {
width: '100%',
},
}}
>
Generate ShareX Config
</Button>
<Button size='xl' onClick={() => setFlameshotOpen(true)} rightIcon={<FlameshotIcon />}>
<Button
size='xl'
onClick={() => setFlameshotOpen(true)}
rightIcon={<FlameshotIcon />}
sx={{
'@media screen and (max-width: 768px)': {
width: '100%',
},
}}
>
Generate Flameshot Script
</Button>
</Group>
<ShareX user={user} open={shareXOpen} setOpen={setShareXOpen} />
<Flameshot user={user} open={flameshotOpen} setOpen={setFlameshotOpen} />
<ClearStorage open={clrStorOpen} setOpen={setClrStorOpen} check={checked} setCheck={setCheck} />
</>
);
}
+13 -8
View File
@@ -1,13 +1,11 @@
import { Button, Center, Group, Skeleton, Stack, Table, TextInput, Title } from '@mantine/core';
import { Button, Center, Group, Skeleton, Table, TextInput, Title } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { CopyIcon } from 'components/icons';
import useFetch from 'hooks/useFetch';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
export default function MetadataView({ fileId }) {
const router = useRouter();
const clipboard = useClipboard();
const [metadata, setMetadata] = useState([]);
@@ -29,11 +27,18 @@ export default function MetadataView({ fileId }) {
const copy = (value) => {
clipboard.copy(value);
showNotification({
title: 'Copied to clipboard',
message: value,
icon: <CopyIcon />,
});
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
else
showNotification({
title: 'Copied to clipboard',
message: value,
icon: <CopyIcon />,
});
};
const searchValue = (value) => {
+83 -199
View File
@@ -1,184 +1,66 @@
import { Box, Card, Grid, LoadingOverlay, MantineTheme, Title, useMantineTheme } from '@mantine/core';
import {
ArcElement,
CategoryScale,
Chart as ChartJS,
ChartData,
ChartOptions,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip,
} from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import ColorHash from 'color-hash';
import { Box, Card, Grid, LoadingOverlay, Title, useMantineTheme } from '@mantine/core';
import { useStats } from 'lib/queries/stats';
import { bytesToHuman } from 'lib/utils/bytes';
import { useMemo } from 'react';
import { Chart, Pie } from 'react-chartjs-2';
import { useEffect, useMemo, useState } from 'react';
const hash = new ColorHash();
ChartJS.register(ArcElement);
ChartJS.register(ChartDataLabels);
ChartJS.register(LinearScale);
ChartJS.register(CategoryScale, PointElement, LineController, LineElement, Tooltip);
const CHART_OPTIONS = (theme: MantineTheme): ChartOptions => ({
plugins: {
tooltip: {
enabled: true,
intersect: false,
},
datalabels: {
display: false,
},
},
scales: {
y: {
ticks: {
callback: (value) => value.toLocaleString(),
color: theme.colors.gray[6],
},
grid: {
color: theme.colors.gray[8],
},
},
x: {
ticks: {
color: theme.colors.gray[6],
},
grid: {
color: theme.colors.gray[8],
},
},
},
});
type LineChartData = ChartData<'line', number[], string>;
type ChartDataMemo = {
views: LineChartData;
uploads: LineChartData;
uploadTypes: ChartData<'pie', number[], string>;
storage: LineChartData;
} | void;
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
export default function Graphs() {
const historicalStats = useStats(10);
const latest = historicalStats.data?.[0];
const theme = useMantineTheme();
const chartOptions = useMemo(() => CHART_OPTIONS(theme), [theme]);
const chartData = useMemo<ChartDataMemo>(() => {
if (historicalStats.isLoading || !historicalStats.data) return;
const chartData = useMemo(() => {
if (historicalStats.isLoading || !historicalStats.data) return null;
const data = Array.from(historicalStats.data).reverse();
const labels = data.map((stat) => new Date(stat.created_at).toLocaleDateString());
const viewData = data.map((stat) => stat.data.views_count);
const uploadData = data.map((stat) => stat.data.count);
const storageData = data.map((stat) => stat.data.size_num);
const views = data.map((stat) => ({
date: new Date(stat.createdAt).toLocaleDateString(),
views: stat.data.views_count,
}));
const uploads = data.map((stat) => ({
date: new Date(stat.createdAt).toLocaleDateString(),
uploads: stat.data.count,
}));
const storage = data.map((stat) => ({
date: new Date(stat.createdAt).toLocaleDateString(),
bytes: stat.data.size_num,
}));
return {
views: {
labels,
datasets: [
{
label: 'Views',
data: viewData,
borderColor: theme.colors.blue[6],
backgroundColor: theme.colors.blue[0],
},
],
},
uploads: {
labels,
datasets: [
{
label: 'Uploads',
data: uploadData,
borderColor: theme.colors.blue[6],
backgroundColor: theme.colors.blue[0],
},
],
},
uploadTypes: {
labels: latest?.data.types_count.map((x) => x.mimetype),
datasets: [
{
data: latest?.data.types_count.map((x) => x.count),
label: 'Upload Types',
backgroundColor: latest?.data.types_count.map((x) => hash.hex(x.mimetype)),
},
],
},
storage: {
labels,
datasets: [
{
label: 'Storage',
data: storageData,
borderColor: theme.colors.blue[6],
backgroundColor: theme.colors.blue[0],
},
],
},
views,
uploads,
storage,
};
}, [historicalStats]);
return (
return historicalStats.isLoading ? (
<LoadingOverlay visible={historicalStats.isLoading} />
) : (
<Box mt='md'>
<LoadingOverlay visible={historicalStats.isLoading} />
<Grid>
{/* 1/4 - upload types */}
<Grid.Col md={12} lg={4}>
<Card>
<Title size='h4'>Upload Types</Title>
{chartData && (
<Pie
data={chartData.uploadTypes}
options={{
plugins: {
datalabels: {
formatter: (_, ctx) => {
// mime: count
const mime = ctx.chart.data.labels[ctx.dataIndex];
const count = ctx.chart.data.datasets[0].data[ctx.dataIndex];
return `${mime}: ${count}`;
},
color: 'white',
textShadowBlur: 7,
textShadowColor: 'black',
},
},
}}
style={{ maxHeight: '20vh' }}
/>
)}
</Card>
</Grid.Col>
{/* 3/4 - views */}
<Grid.Col md={12} lg={8}>
<Grid.Col md={12}>
<Card>
<Title size='h4'>Total Views</Title>
{chartData && (
<Chart
type='line'
data={chartData.views}
options={chartOptions}
style={{ maxHeight: '20vh' }}
/>
<ResponsiveContainer width='100%' height={250}>
<LineChart data={chartData.views}>
<CartesianGrid strokeDasharray='3 3' />
<XAxis dataKey='date' />
<YAxis />
<Tooltip
contentStyle={{
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : 'white',
borderColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : 'white',
}}
/>
<Line type='monotone' dataKey='views' name='Views' stroke='#8884d8' activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
)}
</Card>
</Grid.Col>
@@ -188,12 +70,26 @@ export default function Graphs() {
<Card>
<Title size='h4'>Total Uploads</Title>
{chartData && (
<Chart
type='line'
data={chartData.uploads}
options={chartOptions}
style={{ maxHeight: '20vh' }}
/>
<ResponsiveContainer width='100%' height={250}>
<LineChart data={chartData.uploads}>
<CartesianGrid strokeDasharray='3 3' />
<XAxis dataKey='date' />
<YAxis />
<Tooltip
contentStyle={{
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : 'white',
borderColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : 'white',
}}
/>
<Line
type='monotone'
dataKey='uploads'
name='Uploads'
stroke='#8884d8'
activeDot={{ r: 8 }}
/>
</LineChart>
</ResponsiveContainer>
)}
</Card>
</Grid.Col>
@@ -203,39 +99,27 @@ export default function Graphs() {
<Card>
<Title size='h4'>Storage Usage</Title>
{chartData && (
<Chart
type='line'
data={chartData.storage}
options={{
...chartOptions,
scales: {
...chartOptions.scales,
y: {
...chartOptions.scales.y,
ticks: {
callback: (value) => bytesToHuman(value as number),
color: theme.colors.gray[6],
},
},
},
plugins: {
...chartOptions.plugins,
tooltip: {
...chartOptions.plugins.tooltip,
callbacks: {
label: (context) => {
const value = context.raw as number;
return bytesToHuman(value);
},
},
},
},
}}
style={{ maxHeight: '20vh' }}
/>
<ResponsiveContainer width='100%' height={250}>
<LineChart data={chartData.storage}>
<CartesianGrid strokeDasharray='3 3' />
<XAxis dataKey='date' />
<YAxis width={80} tickFormatter={(value) => bytesToHuman(value as number)} />
<Tooltip
contentStyle={{
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : 'white',
borderColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[3],
}}
formatter={(value) => bytesToHuman(value as number)}
/>
<Line
type='monotone'
stroke='#8884d8'
activeDot={{ r: 8 }}
dataKey='bytes'
name='Storage'
/>
</LineChart>
</ResponsiveContainer>
)}
</Card>
</Grid.Col>
+70 -11
View File
@@ -1,15 +1,39 @@
import { Box, Card, LoadingOverlay } from '@mantine/core';
import { Box, Card, Center, Grid, LoadingOverlay, Title, useMantineTheme } from '@mantine/core';
import { SmallTable } from 'components/SmallTable';
import { useStats } from 'lib/queries/stats';
import { colorHash } from 'lib/utils/client';
import { useEffect, useMemo, useState } from 'react';
import { Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
export default function Types() {
const stats = useStats();
const theme = useMantineTheme();
if (stats.isLoading) return <LoadingOverlay visible />;
const latest = useMemo(() => {
if (stats.isLoading || !stats.data) return null;
const latest = stats.data[0];
return stats.data[0];
}, [stats]);
return (
const chartData = useMemo(() => {
if (!latest) return null;
const data = latest.data.types_count.map((type) => ({
name: type.mimetype,
value: type.count,
fill: colorHash(type.mimetype),
}));
return {
data,
};
}, [latest]);
return !latest ? (
<LoadingOverlay visible={stats.isLoading} />
) : (
<Box mt='md'>
{latest.data.count_by_user.length ? (
<Card>
@@ -23,13 +47,48 @@ export default function Types() {
</Card>
) : null}
<Card>
<SmallTable
columns={[
{ id: 'mimetype', name: 'Type' },
{ id: 'count', name: 'Count' },
]}
rows={latest.data.types_count}
/>
<Title size='h4'>Upload Types</Title>
<Grid>
<Grid.Col md={12} lg={8}>
<SmallTable
columns={[
{ id: 'mimetype', name: 'Type' },
{ id: 'count', name: 'Count' },
]}
rows={latest.data.types_count}
/>
</Grid.Col>
<Grid.Col md={12} lg={4}>
<Center>
{chartData && (
<ResponsiveContainer width='100%' height={250}>
<PieChart>
<Pie
data={chartData.data}
dataKey='value'
nameKey='name'
cx='50%'
cy='50%'
outerRadius={80}
label={({ name, value }) => `${name} (${value})`}
/>
<Tooltip
contentStyle={{
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : 'white',
borderColor:
theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[3],
}}
itemStyle={{
color: theme.colorScheme === 'dark' ? 'white' : 'black',
}}
/>
</PieChart>
</ResponsiveContainer>
)}
</Center>
</Grid.Col>
</Grid>
</Card>
</Box>
);
+59 -28
View File
@@ -1,4 +1,5 @@
import {
Box,
Button,
Collapse,
Group,
@@ -6,6 +7,8 @@ import {
PasswordInput,
Progress,
Select,
Stack,
Text,
Title,
Tooltip,
} from '@mantine/core';
@@ -15,6 +18,7 @@ import { showNotification, updateNotification } from '@mantine/notifications';
import Dropzone from 'components/dropzone/Dropzone';
import FileDropzone from 'components/dropzone/DropzoneFile';
import { ClockIcon, CrossIcon, UploadIcon } from 'components/icons';
import MutedText from 'components/MutedText';
import { invalidateFiles } from 'lib/queries/files';
import { userSelector } from 'lib/recoil/user';
import { expireReadToDate, randomChars } from 'lib/utils/client';
@@ -46,7 +50,7 @@ export default function File({ chunks: chunks_config }) {
});
});
const handleChunkedFiles = async (expires_at: Date, toChunkFiles: File[]) => {
const handleChunkedFiles = async (expiresAt: Date, toChunkFiles: File[]) => {
for (let i = 0; i !== toChunkFiles.length; ++i) {
const file = toChunkFiles[i];
const identifier = randomChars(4);
@@ -124,6 +128,12 @@ export default function File({ chunks: chunks_config }) {
setTimeout(() => setProgress(0), 1000);
clipboard.copy(json.files[0]);
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
}
ready = true;
@@ -149,7 +159,7 @@ export default function File({ chunks: chunks_config }) {
req.setRequestHeader('X-Zipline-Partial-MimeType', file.type);
req.setRequestHeader('X-Zipline-Partial-Identifier', identifier);
req.setRequestHeader('X-Zipline-Partial-LastChunk', j === chunks.length - 1 ? 'true' : 'false');
options.expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expires_at.toISOString());
options.expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expiresAt.toISOString());
options.password.trim() !== '' && req.setRequestHeader('Password', options.password);
options.maxViews &&
options.maxViews !== 0 &&
@@ -159,6 +169,7 @@ export default function File({ chunks: chunks_config }) {
options.embedded && req.setRequestHeader('Embed', 'true');
options.zeroWidth && req.setRequestHeader('Zws', 'true');
options.format !== 'default' && req.setRequestHeader('Format', options.format);
options.originalName && req.setRequestHeader('Original-Name', 'true');
req.send(body);
@@ -168,7 +179,7 @@ export default function File({ chunks: chunks_config }) {
};
const handleUpload = async () => {
const expires_at = options.expires === 'never' ? null : expireReadToDate(options.expires);
const expiresAt = options.expires === 'never' ? null : expireReadToDate(options.expires);
setProgress(0);
setLoading(true);
@@ -195,7 +206,7 @@ export default function File({ chunks: chunks_config }) {
autoClose: false,
});
return handleChunkedFiles(expires_at, toChunkFiles);
return handleChunkedFiles(expiresAt, toChunkFiles);
}
showNotification({
@@ -241,7 +252,7 @@ export default function File({ chunks: chunks_config }) {
autoClose: false,
});
return handleChunkedFiles(expires_at, toChunkFiles);
return handleChunkedFiles(expiresAt, toChunkFiles);
}
} else {
updateNotification({
@@ -260,7 +271,7 @@ export default function File({ chunks: chunks_config }) {
if (bodyLength !== 0) {
req.open('POST', '/api/upload');
req.setRequestHeader('Authorization', user.token);
options.expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expires_at.toISOString());
options.expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expiresAt.toISOString());
options.password.trim() !== '' && req.setRequestHeader('Password', options.password);
options.maxViews &&
options.maxViews !== 0 &&
@@ -270,6 +281,8 @@ export default function File({ chunks: chunks_config }) {
options.embedded && req.setRequestHeader('Embed', 'true');
options.zeroWidth && req.setRequestHeader('Zws', 'true');
options.format !== 'default' && req.setRequestHeader('Format', options.format);
options.originalName && req.setRequestHeader('Original-Name', 'true');
options.overrideDomain && req.setRequestHeader('Override-Domain', options.overrideDomain);
req.send(body);
}
@@ -277,32 +290,50 @@ export default function File({ chunks: chunks_config }) {
return (
<>
<OptionsModal />
{OptionsModal}
<Title mb='md'>Upload Files</Title>
<Dropzone loading={loading} onDrop={(f) => setFiles([...files, ...f])}>
<Group position='center' spacing='md'>
{files.map((file) => (
<FileDropzone key={randomId()} file={file} />
))}
</Group>
<Stack justify='space-between' h='100%'>
{files.length ? (
<Group spacing='md'>
{files.map((file) => (
<FileDropzone
key={randomId()}
file={file}
onRemove={() => setFiles(files.filter((f) => f !== file))}
/>
))}
</Group>
) : (
<Group position='center'>
<MutedText>Files will appear here once you drop/select them</MutedText>
</Group>
)}
<Stack>
<Group position='right' mt='md'>
<Button onClick={() => setOpened(true)} variant='outline'>
Options
</Button>
<Button onClick={() => setFiles([])} color='red' variant='outline'>
Clear Files
</Button>
<Button
leftIcon={<UploadIcon />}
onClick={handleUpload}
disabled={files.length === 0 ? true : false}
>
Upload
</Button>
</Group>
<Collapse in={progress !== 0}>
{progress !== 0 && <Progress mt='md' value={progress} animate />}
</Collapse>
</Stack>
</Stack>
</Dropzone>
<Collapse in={progress !== 0}>
{progress !== 0 && <Progress mt='md' value={progress} animate />}
</Collapse>
<Group position='right' mt='md'>
<Button onClick={() => setOpened(true)} variant='outline'>
Options
</Button>
<Button onClick={() => setFiles([])} color='red' variant='outline'>
Clear Files
</Button>
<Button leftIcon={<UploadIcon />} onClick={handleUpload} disabled={files.length === 0 ? true : false}>
Upload
</Button>
</Group>
</>
);
}
+33 -14
View File
@@ -1,14 +1,15 @@
import { Button, Group, NumberInput, PasswordInput, Select, Tabs, Title, Tooltip } from '@mantine/core';
import { Alert, Button, Card, Container, Group, Select, Tabs, Title } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { showNotification, updateNotification } from '@mantine/notifications';
import { Prism } from '@mantine/prism';
import CodeInput from 'components/CodeInput';
import { ClockIcon, ImageIcon, TypeIcon, UploadIcon } from 'components/icons';
import { ImageIcon, TypeIcon, UploadIcon } from 'components/icons';
import KaTeX from 'components/render/KaTeX';
import Markdown from 'components/render/Markdown';
import PrismCode from 'components/render/PrismCode';
import exts from 'lib/exts';
import { userSelector } from 'lib/recoil/user';
import { expireReadToDate } from 'lib/utils/client';
import { Language } from 'prism-react-renderer';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import showFilesModal from './showFilesModal';
@@ -24,10 +25,13 @@ export default function Text() {
const [options, setOpened, OptionsModal] = useUploadOptions();
const shouldRenderMarkdown = lang === 'md';
const shouldRenderTex = lang === 'tex';
const handleUpload = async () => {
const file = new File([value], 'text.' + lang);
const expires_at = options.expires === 'never' ? null : expireReadToDate(options.expires);
const expiresAt = options.expires === 'never' ? null : expireReadToDate(options.expires);
showNotification({
id: 'upload-text',
@@ -59,20 +63,22 @@ export default function Text() {
req.setRequestHeader('Authorization', user.token);
req.setRequestHeader('UploadText', 'true');
options.expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expires_at.toISOString());
options.expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expiresAt.toISOString());
options.password.trim() !== '' && req.setRequestHeader('Password', options.password);
options.maxViews && options.maxViews !== 0 && req.setRequestHeader('Max-Views', String(options.maxViews));
options.compression !== 'none' && req.setRequestHeader('Image-Compression-Percent', options.compression);
options.embedded && req.setRequestHeader('Embed', 'true');
options.zeroWidth && req.setRequestHeader('Zws', 'true');
options.format !== 'default' && req.setRequestHeader('Format', options.format);
options.originalName && req.setRequestHeader('Original-Name', 'true');
options.overrideDomain && req.setRequestHeader('Override-Domain', options.overrideDomain);
req.send(body);
};
return (
<>
<OptionsModal />
{OptionsModal}
<Title mb='md'>Upload Text</Title>
<Tabs defaultValue='text' variant='pills'>
@@ -90,13 +96,26 @@ export default function Text() {
</Tabs.Panel>
<Tabs.Panel mt='sm' value='preview'>
<Prism
sx={(t) => ({ height: '80vh', backgroundColor: t.colors.dark[8] })}
withLineNumbers
language={lang as Language}
>
{value}
</Prism>
{shouldRenderMarkdown || shouldRenderTex ? (
<>
<Alert color='blue' variant='outline' sx={{ width: '100%' }}>
You are viewing a rendered version of your code
</Alert>
<Container>
<Card p='md' my='sm'>
{shouldRenderMarkdown && <Markdown code={value} />}
{shouldRenderTex && <KaTeX code={value} />}
</Card>
</Container>
</>
) : (
<PrismCode
sx={(t) => ({ height: '100vh', backgroundColor: t.colors.dark[8] })}
code={value}
ext={lang}
/>
)}
</Tabs.Panel>
</Tabs>
+32 -23
View File
@@ -1,17 +1,24 @@
import { Button, Table, Title } from '@mantine/core';
import { ActionIcon, Box, Button, Group, Stack, Table, Title, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { CopyIcon } from 'components/icons';
import { CopyIcon, LinkIcon } from 'components/icons';
import Link from 'components/Link';
export default function showFilesModal(clipboard, modals, files: string[]) {
const open = (idx: number) => window.open(files[idx], '_blank');
const copy = (idx: number) => {
clipboard.copy(files[idx]);
showNotification({
title: 'Copied to clipboard',
message: <Link href={files[idx]}>{files[idx]}</Link>,
icon: <CopyIcon />,
});
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
else
showNotification({
title: 'Copied to clipboard',
message: <Link href={files[idx]}>{files[idx]}</Link>,
icon: <CopyIcon />,
});
};
modals.openModal({
@@ -19,25 +26,27 @@ export default function showFilesModal(clipboard, modals, files: string[]) {
size: 'auto',
children: (
<Table withBorder={false} withColumnBorders={false} highlightOnHover horizontalSpacing={'sm'}>
<tbody>
<Stack>
{files.map((file, idx) => (
<tr key={file}>
<td>
<Group key={idx} position='apart'>
<Group position='left'>
<Link href={file}>{file}</Link>
</td>
<td>
<Button.Group>
<Button variant='outline' onClick={() => copy(idx)}>
Copy
</Button>
<Button variant='outline' onClick={() => open(idx)}>
Open
</Button>
</Button.Group>
</td>
</tr>
</Group>
<Group position='right'>
<Tooltip label='Open link in a new tab'>
<ActionIcon onClick={() => open(idx)} variant='filled' color='primary'>
<LinkIcon />
</ActionIcon>
</Tooltip>
<Tooltip label='Copy link to clipboard'>
<ActionIcon onClick={() => copy(idx)} variant='filled' color='primary'>
<CopyIcon />
</ActionIcon>
</Tooltip>
</Group>
</Group>
))}
</tbody>
</Stack>
</Table>
),
});
+138 -63
View File
@@ -7,61 +7,87 @@ import {
Select,
Stack,
Switch,
TextInput,
Title,
} from '@mantine/core';
import { ClockIcon, ImageIcon, KeyIcon, TypeIcon, UserIcon } from 'components/icons';
import React, { Dispatch, SetStateAction, useState } from 'react';
import { ClockIcon, ImageIcon, KeyIcon, TypeIcon, UserIcon, GlobeIcon } from 'components/icons';
import React, { Dispatch, SetStateAction, useReducer, useState } from 'react';
export default function useUploadOptions(): [
{
expires: string;
password: string;
maxViews: number;
compression: string;
zeroWidth: boolean;
embedded: boolean;
format: string;
},
Dispatch<SetStateAction<boolean>>,
React.FC
] {
const [expires, setExpires] = useState('never');
const [password, setPassword] = useState('');
const [maxViews, setMaxViews] = useState(0);
const [compression, setCompression] = useState<string>('none');
const [zeroWidth, setZeroWidth] = useState(false);
const [embedded, setEmbedded] = useState(false);
const [format, setFormat] = useState('default');
export type UploadOptionsState = {
expires: string;
password: string;
maxViews: number;
compression: string;
zeroWidth: boolean;
embedded: boolean;
format: string;
originalName: boolean;
overrideDomain: string;
};
const [opened, setOpened] = useState(false);
const DEFAULT_OD_DESC = 'Override the default domain(s). Type in a URL, e.g https://example.com';
const reset = () => {
setExpires('never');
setPassword('');
setMaxViews(0);
setCompression('none');
setZeroWidth(false);
setEmbedded(false);
setFormat('default');
export function OptionsModal({
opened,
setOpened,
state,
setState,
reset,
}: {
opened: boolean;
setOpened: Dispatch<SetStateAction<boolean>>;
state: UploadOptionsState;
setState: Dispatch<SetStateAction<any>>;
reset: () => void;
}) {
const [odState, setODState] = useReducer((state, newState) => ({ ...state, ...newState }), {
description: DEFAULT_OD_DESC,
error: '',
});
const handleOD = (e) => {
setODState({ error: '' });
if (e.currentTarget.value === '') {
setODState({ description: DEFAULT_OD_DESC, error: '' });
setState({ overrideDomain: '' });
return;
}
try {
const url = new URL(e.currentTarget.value);
setODState({
description: (
<>
{DEFAULT_OD_DESC}
<br />
<br />
Using domain &quot;<b>{url.hostname}</b>&quot;
</>
),
});
setState({ overrideDomain: url.hostname });
} catch (e) {
setODState({ error: 'Invalid URL' });
}
};
const OptionsModal: React.FC = () => (
<Modal title={<Title>Upload Options</Title>} size='auto' opened={opened} onClose={() => setOpened(false)}>
return (
<Modal title={<Title>Upload Options</Title>} size='lg' opened={opened} onClose={() => setOpened(false)}>
<Stack>
<NumberInput
label='Max Views'
description='The maximum number of times this file can be viewed. Leave blank for unlimited views.'
value={maxViews}
onChange={setMaxViews}
value={state.maxViews}
onChange={(e) => setState({ maxViews: e })}
min={0}
icon={<UserIcon />}
/>
<Select
label='Expires'
description='The date and time this file will expire. Leave blank for never.'
value={expires}
onChange={(e) => setExpires(e)}
value={state.expires}
onChange={(e) => setState({ expires: e })}
icon={<ClockIcon size={14} />}
data={[
{ value: 'never', label: 'Never' },
@@ -92,28 +118,37 @@ export default function useUploadOptions(): [
{ value: '6m', label: '6 months' },
{ value: '8m', label: '8 months' },
{ value: '1y', label: '1 year' },
{
value: null,
label: 'Need more freedom? Set an exact date and time through the API.',
disabled: true,
},
]}
/>
<Select
label='Compression'
description='The compression level to use when uploading this file. Leave blank for default.'
value={compression}
onChange={(e) => setCompression(e)}
value={state.compression}
onChange={(e) => setState({ compression: e })}
icon={<ImageIcon />}
data={[
{ value: 'none', label: 'None' },
{ value: '25', label: 'Low (25%)' },
{ value: '50', label: 'Medium (50%)' },
{ value: '75', label: 'High (75%)' },
{ value: '100', label: 'Maximum (100%)' },
{
value: null,
label: 'Need more freedom? Set a custom compression level through the API.',
disabled: true,
},
]}
/>
<Select
label='Format'
description="The file name format to use when uploading this file. Leave blank for the server's default."
value={format}
onChange={(e) => setFormat(e)}
value={state.format}
onChange={(e) => setState({ format: e })}
icon={<TypeIcon />}
data={[
{ value: 'default', label: 'Default' },
@@ -123,31 +158,42 @@ export default function useUploadOptions(): [
{ value: 'UUID', label: 'UUID' },
]}
/>
<PasswordInput
label='Password'
description='The password required to view this file. Leave blank for no password.'
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
value={state.password}
onChange={(e) => setState({ password: e.currentTarget.value })}
icon={<KeyIcon />}
/>
<TextInput
label='Override Domain'
onChange={handleOD}
icon={<GlobeIcon />}
description={odState.description}
error={odState.error}
/>
<Group>
<Switch
label='Zero Width'
description='Whether or not to use zero width characters for the file name.'
checked={zeroWidth}
onChange={(e) => setZeroWidth(e.currentTarget.checked)}
checked={state.zeroWidth}
onChange={(e) => setState({ zeroWidth: e.currentTarget.checked })}
/>
<Switch
label='Embedded'
description='Whether or not to embed with OG tags for this file.'
checked={embedded}
onChange={(e) => setEmbedded(e.currentTarget.checked)}
checked={state.embedded}
onChange={(e) => setState({ embedded: e.currentTarget.checked })}
/>
<Switch
label='Keep Original Name'
description='Whether or not to show the original name when downloading this specific file. This will not change the name format in the URL.'
checked={state.originalName}
onChange={(e) => setState({ originalName: e.currentTarget.checked })}
/>
</Group>
<Group grow>
<Button onClick={() => reset()} color='red'>
Reset Options
@@ -157,18 +203,47 @@ export default function useUploadOptions(): [
</Stack>
</Modal>
);
}
export default function useUploadOptions(): [UploadOptionsState, Dispatch<SetStateAction<boolean>>, any] {
const [state, setState] = useReducer((state, newState) => ({ ...state, ...newState }), {
expires: 'never',
password: '',
maxViews: 0,
compression: 'none',
zeroWidth: false,
embedded: false,
format: 'default',
originalName: false,
overrideDomain: '',
} as UploadOptionsState);
const [opened, setOpened] = useState(false);
const reset = () => {
setState({
expires: 'never',
password: '',
maxViews: 0,
compression: 'none',
zeroWidth: false,
embedded: false,
format: 'default',
originalName: false,
overrideDomain: '',
});
};
return [
{
expires,
password,
maxViews,
compression,
zeroWidth,
embedded,
format,
},
state,
setOpened,
OptionsModal,
<OptionsModal
state={state}
setState={setState}
reset={reset}
opened={opened}
setOpened={setOpened}
key={1}
/>,
];
}
+16 -9
View File
@@ -14,11 +14,18 @@ export default function URLCard({ url }: { url: URLResponse }) {
const copyURL = (u) => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
else
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
};
const deleteURL = async (u) => {
@@ -51,9 +58,9 @@ export default function URLCard({ url }: { url: URLResponse }) {
<Group position='left'>
<Stack spacing={0}>
<Title>{url.vanity ?? url.id}</Title>
<Tooltip label={new Date(url.created_at).toLocaleString()}>
<Tooltip label={new Date(url.createdAt).toLocaleString()}>
<div>
<MutedText size='sm'>Created: {relativeTime(new Date(url.created_at))}</MutedText>
<MutedText size='sm'>Created: {relativeTime(new Date(url.createdAt))}</MutedText>
</div>
</Tooltip>
{url.vanity && <MutedText size='sm'>ID: {url.id}</MutedText>}
@@ -70,7 +77,7 @@ export default function URLCard({ url }: { url: URLResponse }) {
</MutedText>
</Stack>
</Group>
<Group position='right'>
<Stack>
<ActionIcon href={url.url} component='a' target='_blank'>
<ExternalLinkIcon />
</ActionIcon>
@@ -80,7 +87,7 @@ export default function URLCard({ url }: { url: URLResponse }) {
<ActionIcon aria-label='delete' onClick={() => deleteURL(url)}>
<TrashIcon />
</ActionIcon>
</Group>
</Stack>
</Group>
</Card>
);
+1 -1
View File
@@ -102,7 +102,7 @@ export default function Urls() {
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput id='url' label='URL' {...form.getInputProps('url')} />
<TextInput id='vanity' label='Vanity' {...form.getInputProps('vanity')} />
<NumberInput id='maxViews' label='Max Views' {...form.getInputProps('maxViews')} />
<NumberInput id='maxViews' label='Max Views' {...form.getInputProps('maxViews')} min={0} />
<Group position='right' mt='md'>
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
+3 -3
View File
@@ -55,7 +55,7 @@ export default function Users() {
title: `Delete ${user.username}'s files?`,
labels: { confirm: 'Yes', cancel: 'No' },
centered: true,
overlayBlur: 3,
overlayProps: { blur: 3 },
onConfirm: () => {
handleDelete(user, true);
modals.closeAll();
@@ -113,7 +113,7 @@ export default function Users() {
<MutedText size='sm'>Administrator: {user.administrator ? 'yes' : 'no'}</MutedText>
</Stack>
</Group>
<Group position='right'>
<Stack>
{user.administrator && !self.superAdmin ? null : (
<>
<ActionIcon
@@ -130,7 +130,7 @@ export default function Users() {
</ActionIcon>
</>
)}
</Group>
</Stack>
</Group>
</Card>
))
+51
View File
@@ -0,0 +1,51 @@
import { Alert } from '@mantine/core';
import katex, { ParseError } from 'katex';
import { useEffect, useState } from 'react';
import 'katex/dist/katex.min.css';
const sanitize = (str: string) => {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
};
export default function KaTeX({ code, ...props }) {
const [rendered, setRendered] = useState('');
const [error, setError] = useState<JSX.Element>();
const renderError = (error: ParseError | TypeError) => {
return (
<Alert title={error.name} color='red'>
{sanitize(error.message)}
</Alert>
);
};
useEffect(() => {
try {
const html = katex.renderToString(code, {
displayMode: true,
throwOnError: true,
errorColor: '#f44336',
});
setRendered(html);
} catch (e) {
if (e instanceof Error) {
setError(renderError(e));
} else {
throw e;
}
}
}, [rendered, error, code]);
if (error) return error;
return (
<div
dangerouslySetInnerHTML={{
__html: rendered,
}}
{...props}
/>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { Code } from '@mantine/core';
import { Prism } from '@mantine/prism';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
export default function Markdown({ code, ...props }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
// @ts-ignore
<Prism language={match[1]} {...props}>
{String(children).replace(/\n$/, '')}
</Prism>
) : (
<Code {...props}>{children}</Code>
);
},
img({ node, ...props }) {
return <img {...props} style={{ maxWidth: '100%' }} />;
},
}}
{...props}
>
{code}
</ReactMarkdown>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { Prism } from '@mantine/prism';
import { Prism as PrismLib } from 'prism-react-renderer';
import exts, { extToPrismComponent } from 'lib/exts';
import { useEffect } from 'react';
(typeof window === 'undefined' ? global : window).Prism = PrismLib;
export default function PrismCode({ code, ext, ...props }) {
useEffect(() => {
(async () => {
const component = extToPrismComponent[ext];
if (component && ext !== 'txt') await import(`prismjs/components/prism-${component}`);
})();
}, [ext]);
return (
<Prism
sx={(t) => ({ height: '100vh', backgroundColor: t.colors.dark[8] })}
withLineNumbers
language={exts[ext]?.toLowerCase()}
{...props}
>
{code}
</Prism>
);
}
+7 -3
View File
@@ -1,7 +1,11 @@
import { Config } from './config/Config';
import readConfig from './config/readConfig';
import validateConfig from './config/validateConfig';
import { Config } from 'config/Config';
import readConfig from 'config/readConfig';
import validateConfig from 'config/validateConfig';
if (!global.config) global.config = validateConfig(readConfig());
export default global.config as Config;
declare global {
var config: Config;
}
+17 -4
View File
@@ -1,5 +1,5 @@
export interface ConfigCore {
https: boolean;
return_https: boolean;
secret: string;
host: string;
port: number;
@@ -74,15 +74,18 @@ export interface ConfigWebsiteExternalLinks {
}
export interface ConfigDiscord {
url: string;
username: string;
avatar_url: string;
url?: string;
username?: string;
avatar_url?: string;
upload: ConfigDiscordContent;
shorten: ConfigDiscordContent;
}
export interface ConfigDiscordContent {
url?: string;
username?: string;
avatar_url?: string;
content: string;
embed: ConfigDiscordEmbed;
}
@@ -102,9 +105,12 @@ export interface ConfigFeatures {
invites_length: number;
oauth_registration: boolean;
oauth_login_only: boolean;
user_registration: boolean;
headless: boolean;
default_avatar: string;
}
export interface ConfigOAuth {
@@ -133,6 +139,12 @@ export interface ConfigExif {
remove_gps: boolean;
}
export interface ConfigSsl {
allow_http1: boolean;
key: string;
cert: string;
}
export interface Config {
core: ConfigCore;
uploader: ConfigUploader;
@@ -146,4 +158,5 @@ export interface Config {
chunks: ConfigChunks;
mfa: ConfigMfa;
exif: ConfigExif;
ssl: ConfigSsl;
}
+22 -5
View File
@@ -1,11 +1,11 @@
import { parse } from 'dotenv';
import { expand } from 'dotenv-expand';
import { existsSync, readFileSync } from 'fs';
import Logger from '../logger';
import { humanToBytes } from '../utils/bytes';
import { parseExpiry } from '../utils/client';
import { resolve } from 'path';
import Logger from 'lib/logger';
import { humanToBytes } from 'utils/bytes';
export type ValueType = 'string' | 'number' | 'boolean' | 'array' | 'json-array' | 'human-to-byte';
export type ValueType = 'string' | 'number' | 'boolean' | 'array' | 'json-array' | 'human-to-byte' | 'path';
function isObject(value: any): value is Record<string, any> {
return typeof value === 'object' && value !== null;
@@ -56,7 +56,7 @@ export default function readConfig() {
}
const maps = [
map('CORE_HTTPS', 'boolean', 'core.https'),
map('CORE_RETURN_HTTPS', 'boolean', 'core.return_https'),
map('CORE_SECRET', 'string', 'core.secret'),
map('CORE_HOST', 'string', 'core.host'),
map('CORE_PORT', 'number', 'core.port'),
@@ -107,6 +107,9 @@ export default function readConfig() {
map('DISCORD_USERNAME', 'string', 'discord.username'),
map('DISCORD_AVATAR_URL', 'string', 'discord.avatar_url'),
map('DISCORD_UPLOAD_URL', 'string', 'discord.upload.url'),
map('DISCORD_UPLOAD_USERNAME', 'string', 'discord.upload.username'),
map('DISCORD_UPLOAD_AVATAR_URL', 'string', 'discord.upload.avatar_url'),
map('DISCORD_UPLOAD_CONTENT', 'string', 'discord.upload.content'),
map('DISCORD_UPLOAD_EMBED_TITLE', 'string', 'discord.upload.embed.title'),
map('DISCORD_UPLOAD_EMBED_DESCRIPTION', 'string', 'discord.upload.embed.description'),
@@ -116,6 +119,9 @@ export default function readConfig() {
map('DISCORD_UPLOAD_EMBED_THUMBNAIL', 'boolean', 'discord.upload.embed.thumbnail'),
map('DISCORD_UPLOAD_EMBED_TIMESTAMP', 'boolean', 'discord.upload.embed.timestamp'),
map('DISCORD_SHORTEN_URL', 'string', 'discord.shorten.url'),
map('DISCORD_SHORTEN_USERNAME', 'string', 'discord.shorten.username'),
map('DISCORD_SHORTEN_AVATAR_URL', 'string', 'discord.shorten.avatar_url'),
map('DISCORD_SHORTEN_CONTENT', 'string', 'discord.shorten.content'),
map('DISCORD_SHORTEN_EMBED_TITLE', 'string', 'discord.shorten.embed.title'),
map('DISCORD_SHORTEN_EMBED_DESCRIPTION', 'string', 'discord.shorten.embed.description'),
@@ -138,10 +144,13 @@ export default function readConfig() {
map('FEATURES_INVITES_LENGTH', 'number', 'features.invites_length'),
map('FEATURES_OAUTH_REGISTRATION', 'boolean', 'features.oauth_registration'),
map('FEATURES_OAUTH_LOGIN_ONLY', 'boolean', 'features.oauth_login_only'),
map('FEATURES_USER_REGISTRATION', 'boolean', 'features.user_registration'),
map('FEATURES_HEADLESS', 'boolean', 'features.headless'),
map('FEATURES_DEFAULT_AVATAR', 'path', 'features.default_avatar'),
map('CHUNKS_MAX_SIZE', 'human-to-byte', 'chunks.max_size'),
map('CHUNKS_CHUNKS_SIZE', 'human-to-byte', 'chunks.chunks_size'),
@@ -150,6 +159,10 @@ export default function readConfig() {
map('EXIF_ENABLED', 'boolean', 'exif.enabled'),
map('EXIF_REMOVE_GPS', 'boolean', 'exif.remove_gps'),
map('SSL_KEY', 'path', 'ssl.key'),
map('SSL_CERT', 'path', 'ssl.cert'),
map('SSL_ALLOW_HTTP1', 'boolean', 'ssl.allow_http1'),
];
const config = {};
@@ -186,6 +199,10 @@ export default function readConfig() {
parsed = humanToBytes(value) ?? undefined;
if (!parsed) logger.debug(`Unable to parse ${map.env}=${value}`);
break;
case 'path':
parsed = resolve(value);
if (!existsSync(parsed)) logger.debug(`Unable to find ${map.env}=${value} (path does not exist)`);
break;
default:
parsed = value;
+39 -9
View File
@@ -1,11 +1,14 @@
import { s } from '@sapphire/shapeshift';
import { Config } from 'lib/config/Config';
import type { Config } from './Config';
import { inspect } from 'util';
import Logger from '../logger';
import { humanToBytes } from '../utils/bytes';
import Logger from 'lib/logger';
import { humanToBytes } from 'utils/bytes';
const discord_content = s
.object({
url: s.string.nullish.default(null),
username: s.string.nullish.default(null),
avatar_url: s.string.nullish.default(null),
content: s.string.nullish.default(null),
embed: s
.object({
@@ -23,7 +26,7 @@ const discord_content = s
const validator = s.object({
core: s.object({
https: s.boolean.default(false),
return_https: s.boolean.default(false),
secret: s.string.lengthGreaterThanOrEqual(8),
host: s.string.default('0.0.0.0'),
port: s.number.default(3000),
@@ -141,11 +144,9 @@ const validator = s.object({
}),
discord: s
.object({
url: s.string,
username: s.string.default('Zipline'),
avatar_url: s.string.default(
'https://raw.githubusercontent.com/diced/zipline/9b60147e112ec5b70170500b85c75ea621f41d03/public/zipline.png'
),
url: s.string.nullish.default(null),
username: s.string.nullish.default(null),
avatar_url: s.string.nullish.default(null),
upload: discord_content,
shorten: discord_content,
})
@@ -167,15 +168,19 @@ const validator = s.object({
invites: s.boolean.default(false),
invites_length: s.number.default(6),
oauth_registration: s.boolean.default(false),
oauth_login_only: s.boolean.default(false),
user_registration: s.boolean.default(false),
headless: s.boolean.default(false),
default_avatar: s.string.nullable.default(null),
})
.default({
invites: false,
invites_length: 6,
oauth_registration: false,
oauth_login_only: false,
user_registration: false,
headless: false,
default_avatar: null,
}),
chunks: s
.object({
@@ -204,6 +209,13 @@ const validator = s.object({
enabled: false,
remove_gps: false,
}),
ssl: s
.object({
key: s.string,
cert: s.string,
allow_http1: s.boolean.default(false),
})
.optional.nullish.default(null),
});
export default function validate(config): Config {
@@ -238,10 +250,28 @@ export default function validate(config): Config {
}
}
const reserved = ['/view', '/dashboard', '/code', '/folder', '/api', '/auth'];
if (reserved.some((r) => validated.uploader.route.startsWith(r))) {
throw {
errors: [`The uploader route cannot be ${validated.uploader.route}, this is a reserved route.`],
show: true,
};
} else if (reserved.some((r) => validated.urls.route.startsWith(r))) {
throw {
errors: [`The urls route cannot be ${validated.urls.route}, this is a reserved route.`],
show: true,
};
}
return validated as unknown as Config;
} catch (e) {
if (process.env.ZIPLINE_DOCKER_BUILD) return null;
if (e.show) {
Logger.get('config').error('Config is invalid, see below:').error(e.errors.join('\n'));
process.exit(1);
}
logger.debug(`config error: ${inspect(e, { depth: Infinity })}`);
e.stack = '';
+4
View File
@@ -24,3 +24,7 @@ if (!global.datasource) {
}
export default global.datasource as Datasource;
declare global {
var datasource: Datasource;
}
+1
View File
@@ -5,6 +5,7 @@ export abstract class Datasource {
public abstract save(file: string, data: Buffer): Promise<void>;
public abstract delete(file: string): Promise<void>;
public abstract clear(): Promise<void>;
public abstract size(file: string): Promise<number>;
public abstract get(file: string): Readable | Promise<Readable>;
public abstract fullSize(): Promise<number>;
+7
View File
@@ -18,6 +18,13 @@ export class Local extends Datasource {
await rm(join(process.cwd(), this.path, file));
}
public async clear(): Promise<void> {
const files = await readdir(join(process.cwd(), this.path));
for (let i = 0; i !== files.length; ++i) {
await rm(join(process.cwd(), this.path, files[i]));
}
}
public get(file: string): ReadStream {
const full = join(process.cwd(), this.path, file);
if (!existsSync(full)) return null;
+12
View File
@@ -28,6 +28,18 @@ export class S3 extends Datasource {
await this.s3.removeObject(this.config.bucket, file);
}
public async clear(): Promise<void> {
const objects = this.s3.listObjectsV2(this.config.bucket, '', true);
const files = [];
objects.on('data', (item) => files.push(item.name));
objects.on('end', async () => {
this.s3.removeObjects(this.config.bucket, files, (err) => {
if (err) throw err;
});
});
}
public get(file: string): Promise<Readable> {
return new Promise((res, rej) => {
this.s3.getObject(this.config.bucket, file, (err, stream) => {
+37 -2
View File
@@ -1,7 +1,7 @@
import { Datasource } from '.';
import { ConfigSupabaseDatasource } from 'lib/config/Config';
import { guess } from '../mimes';
import Logger from '../logger';
import { guess } from 'lib/mimes';
import Logger from 'lib/logger';
import { Readable } from 'stream';
export class Supabase extends Datasource {
@@ -37,6 +37,41 @@ export class Supabase extends Datasource {
});
}
public async clear(): Promise<void> {
try {
const resp = await fetch(`${this.config.url}/storage/v1/object/list/${this.config.bucket}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.config.key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prefix: '',
}),
});
const objs = await resp.json();
if (objs.error) throw new Error(`${objs.error}: ${objs.message}`);
const res = await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${this.config.key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prefixes: objs.map((x: { name: string }) => x.name),
}),
});
const j = await res.json();
if (j.error) throw new Error(`${j.error}: ${j.message}`);
return;
} catch (e) {
this.logger.error(e);
}
}
public async get(file: string): Promise<Readable> {
// get a readable stream from the request
const r = await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}/${file}`, {
+41 -56
View File
@@ -1,54 +1,22 @@
import { Image, Url, User } from '@prisma/client';
import { File, Url, User } from '@prisma/client';
import config from 'lib/config';
import { ConfigDiscordContent } from 'lib/config/Config';
import Logger from './logger';
// [user, image, url, route (ex. https://example.com/r/something.png)]
export type Args = [User, Image?, Url?, string?];
import { ConfigDiscordContent } from 'config/Config';
import Logger from 'lib/logger';
import { parseString, ParseValue } from 'utils/parser';
const logger = Logger.get('discord');
function parse(str: string, args: Args) {
if (!str) return null;
str = str
.replace(/{user\.admin}/gi, args[0].administrator ? 'yes' : 'no')
.replace(/{user\.id}/gi, args[0].id.toString())
.replace(/{user\.name}/gi, args[0].username)
.replace(/{link}/gi, args[3]);
if (args[1])
str = str
.replace(/{file\.id}/gi, args[1].id.toString())
.replace(/{file\.mime}/gi, args[1].mimetype)
.replace(/{file\.file}/gi, args[1].file)
.replace(/{file\.created_at.full_string}/gi, args[1].created_at.toLocaleString())
.replace(/{file\.created_at.time_string}/gi, args[1].created_at.toLocaleTimeString())
.replace(/{file\.created_at.date_string}/gi, args[1].created_at.toLocaleDateString());
if (args[2])
str = str
.replace(/{url\.id}/gi, args[2].id.toString())
.replace(/{url\.vanity}/gi, args[2].vanity ? args[2].vanity : 'none')
.replace(/{url\.destination}/gi, args[2].destination)
.replace(/{url\.created_at.full_string}/gi, args[2].created_at.toLocaleString())
.replace(/{url\.created_at.time_string}/gi, args[2].created_at.toLocaleTimeString())
.replace(/{url\.created_at.date_string}/gi, args[2].created_at.toLocaleDateString());
return str;
}
export function parseContent(
content: ConfigDiscordContent,
args: Args
args: ParseValue
): ConfigDiscordContent & { url: string } {
return {
content: parse(content.content, args),
content: content.content ? parseString(content.content, args) : null,
embed: content.embed
? {
title: parse(content.embed.title, args),
description: parse(content.embed.description, args),
footer: parse(content.embed.footer, args),
title: content.embed.title ? parseString(content.embed.title, args) : null,
description: content.embed.description ? parseString(content.embed.description, args) : null,
footer: content.embed.footer ? parseString(content.embed.footer, args) : null,
color: content.embed.color,
thumbnail: content.embed.thumbnail,
timestamp: content.embed.timestamp,
@@ -59,15 +27,25 @@ export function parseContent(
};
}
export async function sendUpload(user: User, image: Image, host: string) {
export async function sendUpload(user: User, file: File, raw_link: string, link: string) {
if (!config.discord.upload) return;
if (!config.discord.url && !config.discord.upload.url) return;
const parsed = parseContent(config.discord.upload, [user, image, null, host]);
const isImage = image.mimetype.startsWith('image/');
logger.debug(`discord config:\n${JSON.stringify(config.discord)}`);
const parsed = parseContent(config.discord.upload, {
file,
user,
link,
raw_link,
});
const isImage = file.mimetype.startsWith('image/');
const body = JSON.stringify({
username: config.discord.username,
avatar_url: config.discord.avatar_url,
username: (config.discord.username ?? config.discord.upload.username) || 'Zipline',
avatar_url:
(config.discord.avatar_url ?? config.discord.upload.avatar_url) ||
'https://raw.githubusercontent.com/diced/zipline/9b60147e112ec5b70170500b85c75ea621f41d03/public/zipline.png',
content: parsed.content ?? null,
embeds: parsed.embed
? [
@@ -75,7 +53,7 @@ export async function sendUpload(user: User, image: Image, host: string) {
title: parsed.embed.title ?? null,
description: parsed.embed.description ?? null,
url: parsed.url ?? null,
timestamp: parsed.embed.timestamp ? image.created_at.toISOString() : null,
timestamp: parsed.embed.timestamp ? file.createdAt.toISOString() : null,
color: parsed.embed.color ?? null,
footer: parsed.embed.footer
? {
@@ -99,8 +77,8 @@ export async function sendUpload(user: User, image: Image, host: string) {
: null,
});
logger.debug('attempting to send shorten notification to discord', body);
const res = await fetch(config.discord.url, {
logger.debug('attempting to send upload notification to discord', body);
const res = await fetch(config.discord.url || config.discord.upload.url, {
method: 'POST',
body,
headers: {
@@ -111,21 +89,28 @@ export async function sendUpload(user: User, image: Image, host: string) {
if (!res.ok) {
const text = await res.text();
logger
.error(`Failed to send shorten notification to discord: ${res.status}`)
.error(`Failed to send upload notification to discord: ${res.status}`)
.error(`Received response:\n${text}`);
}
return;
}
export async function sendShorten(user: User, url: Url, host: string) {
export async function sendShorten(user: User, url: Url, link: string) {
if (!config.discord.shorten) return;
if (!config.discord.url && !config.discord.shorten.url) return;
const parsed = parseContent(config.discord.shorten, [user, null, url, host]);
const parsed = parseContent(config.discord.shorten, {
url,
user,
link,
});
const body = JSON.stringify({
username: config.discord.username,
avatar_url: config.discord.avatar_url,
username: (config.discord.username ?? config.discord.shorten.username) || 'Zipline',
avatar_url:
(config.discord.avatar_url ?? config.discord.shorten.avatar_url) ||
'https://raw.githubusercontent.com/diced/zipline/9b60147e112ec5b70170500b85c75ea621f41d03/public/zipline.png',
content: parsed.content ?? null,
embeds: parsed.embed
? [
@@ -133,7 +118,7 @@ export async function sendShorten(user: User, url: Url, host: string) {
title: parsed.embed.title ?? null,
description: parsed.embed.description ?? null,
url: parsed.url ?? null,
timestamp: parsed.embed.timestamp ? url.created_at.toISOString() : null,
timestamp: parsed.embed.timestamp ? url.createdAt.toISOString() : null,
color: parsed.embed.color ?? null,
footer: parsed.embed.footer
? {
@@ -146,7 +131,7 @@ export async function sendShorten(user: User, url: Url, host: string) {
});
logger.debug('attempting to send shorten notification to discord', body);
const res = await fetch(config.discord.url, {
const res = await fetch(config.discord.url || config.discord.shorten.url, {
method: 'POST',
body,
headers: {
+50 -15
View File
@@ -1,45 +1,80 @@
// https://github.com/toptal/haste-server/blob/master/static/application.js#L167-L174
// Popular extension map
const exts = {
// eslint-disable-next-line import/no-anonymous-default-export
export default {
md: 'Markdown',
css: 'CSS',
js: 'JavaScript',
json: 'JSON',
html: 'HTML',
ts: 'TypeScript',
java: 'Java',
py: 'Python',
rb: 'Ruby',
sh: 'Shell',
sh: 'Bash',
php: 'PHP',
pl: 'Perl',
perl: 'Perl',
sql: 'SQL',
xml: 'XML',
yml: 'YAML',
yaml: 'YAML',
c: 'C',
cpp: 'C++',
cs: 'C#',
go: 'Go',
h: 'C/C++ Header',
txt: 'Text',
dockerfile: 'Dockerfile',
docker: 'Docker',
toml: 'TOML',
ini: 'INI',
bat: 'Batch File',
bat: 'Batch',
tex: 'TeX',
r: 'R',
lua: 'Lua',
ps1: 'PowerShell',
rst: 'reStructuredText',
rs: 'Rust',
swift: 'Swift',
scss: 'SCSS',
less: 'LESS',
scala: 'Scala',
kotlin: 'Kotlin',
kt: 'Kotlin',
vb: 'Visual Basic',
json: 'JSON',
vim: 'Vim Script',
txt: 'Plain Text',
html: 'HTML',
};
export default exts;
export const extToPrismComponent = (ext: string) => {
// await import(prismjs/components/prism-${extToPrismComponent(ext)}.js)
return {
md: 'markdown',
css: 'css',
js: 'javascript',
ts: 'typescript',
java: 'java',
py: 'python',
rb: 'ruby',
sh: 'Bash',
php: 'php',
perl: 'perl',
sql: 'sql',
xml: 'xml-doc',
yaml: 'yaml',
c: 'c',
cpp: 'cpp',
cs: 'csharp',
go: 'go',
docker: 'docker',
toml: 'toml',
ini: 'ini',
bat: 'batch',
tex: 'latex',
r: 'r',
lua: 'lua',
ps1: 'powershell',
rs: 'rust',
swift: 'swift',
scss: 'scss',
less: 'less',
scala: 'scala',
kt: 'kotlin',
vb: 'visual-basic',
json: 'json',
vim: 'vim',
html: 'html',
}[ext];
};
+33 -6
View File
@@ -1,4 +1,11 @@
import { blueBright, cyan, red, yellow } from 'colorette';
const COLORS = {
blueBright: (str: string) => `\x1b[34m${str}\x1b[0m`,
cyan: (str: string) => `\x1b[36m${str}\x1b[0m`,
red: (str: string) => `\x1b[31m${str}\x1b[0m`,
yellow: (str: string) => `\x1b[33m${str}\x1b[0m`,
gray: (str: string) => `\x1b[90m${str}\x1b[0m`,
};
import dayjs from 'dayjs';
export enum LoggerLevel {
@@ -10,6 +17,10 @@ export enum LoggerLevel {
export default class Logger {
public name: string;
static filters(): string[] {
return (process.env.LOGGER_FILTERS ?? '').split(',').filter((x) => x !== '');
}
static get(klass: any) {
if (typeof klass !== 'function') if (typeof klass !== 'string') throw new Error('not string/function');
@@ -22,13 +33,28 @@ export default class Logger {
this.name = name;
}
child(name: string) {
return new Logger(`${this.name}::${name}`);
}
show(): boolean {
const filters = Logger.filters();
if (!filters.length) return true;
return filters.includes(this.name);
}
info(...args: any[]): this {
if (!this.show()) return this;
process.stdout.write(this.formatMessage(LoggerLevel.INFO, this.name, args.join(' ')));
return this;
}
error(...args: any[]): this {
if (!this.show()) return this;
process.stdout.write(
this.formatMessage(LoggerLevel.ERROR, this.name, args.map((error) => error.stack ?? error).join(' '))
);
@@ -37,7 +63,8 @@ export default class Logger {
}
debug(...args: any[]): this {
if (!process.env.DEBUG) return;
if (!process.env.DEBUG) return this;
if (!this.show()) return this;
process.stdout.write(this.formatMessage(LoggerLevel.DEBUG, this.name, args.join(' ')));
@@ -46,17 +73,17 @@ export default class Logger {
formatMessage(level: LoggerLevel, name: string, message: string) {
const time = dayjs().format('YYYY-MM-DD hh:mm:ss,SSS A');
return `${time} ${this.formatLevel(level)} [${blueBright(name)}] ${message}\n`;
return `${COLORS.gray(time)} ${this.formatLevel(level)} [${COLORS.blueBright(name)}] ${message}\n`;
}
formatLevel(level: LoggerLevel) {
switch (level) {
case LoggerLevel.INFO:
return cyan('info ');
return COLORS.cyan('info ');
case LoggerLevel.ERROR:
return red('error');
return COLORS.red('error');
case LoggerLevel.DEBUG:
return yellow('debug');
return COLORS.yellow('debug');
}
}
}
+5
View File
@@ -21,6 +21,7 @@ export type ServerSideProps = {
totp_enabled: boolean;
exif_enabled: boolean;
fileId?: string;
queryPage?: string;
};
export const getServerSideProps: GetServerSideProps<ServerSideProps> = async (ctx) => {
@@ -71,5 +72,9 @@ export const getServerSideProps: GetServerSideProps<ServerSideProps> = async (ct
obj.props.fileId = ctx.query.id as string;
}
if (ctx.resolvedUrl.startsWith('/dashboard/files')) {
obj.props.queryPage = (ctx.query.page as string) || '1';
}
return obj;
};
+98 -49
View File
@@ -13,6 +13,7 @@ export interface OAuthQuery {
export interface OAuthResponse {
username?: string;
user_id?: string;
access_token?: string;
refresh_token?: string;
avatar?: string;
@@ -32,8 +33,7 @@ export const withOAuth =
function oauthError(error: string) {
if (config.features.headless)
return res.json({
error,
return res.badRequest(error, {
provider,
});
@@ -56,23 +56,51 @@ export const withOAuth =
const { state } = req.query as { state?: string };
const existing = await prisma.user.findFirst({
where: {
oauth: {
some: {
let existingOauth;
try {
existingOauth = await prisma.oAuth.findUniqueOrThrow({
where: {
provider_oauthId: {
provider: provider.toUpperCase() as OauthProviders,
username: oauth_resp.username,
oauthId: oauth_resp.user_id as string,
},
},
});
} catch (e) {
logger.debug(`Failed to find existing oauth. Using fallback. ${e}`);
if (e.code === 'P2022' || e.code === 'P2025') {
const existing = await prisma.user.findFirst({
where: {
oauth: {
some: {
provider: provider.toUpperCase() as OauthProviders,
username: oauth_resp.username,
},
},
},
include: {
oauth: true,
},
});
existingOauth = existing?.oauth?.find((o) => o.provider === provider.toUpperCase());
if (existingOauth) existingOauth.fallback = true;
} else {
logger.error(`Failed to find existing oauth. ${e}`);
}
}
const existingUser = await prisma.user.findFirst({
where: {
username: oauth_resp.username,
},
include: {
oauth: true,
select: {
username: true,
id: true,
},
});
const user = await req.user();
const existingOauth = existing?.oauth?.find((o) => o.provider === provider.toUpperCase());
const userOauth = user?.oauth?.find((o) => o.provider === provider.toUpperCase());
if (state === 'link') {
@@ -82,22 +110,30 @@ export const withOAuth =
return oauthError(`This account was already linked with ${provider}!`);
logger.debug(`attempting to link ${provider} account to ${user.username}`);
await prisma.user.update({
where: {
id: user.id,
},
data: {
oauth: {
create: {
provider: OauthProviders[provider.toUpperCase()],
token: oauth_resp.access_token,
refresh: oauth_resp.refresh_token || null,
username: oauth_resp.username,
},
try {
await prisma.user.update({
where: {
id: user.id,
},
avatar: oauth_resp.avatar,
},
});
data: {
oauth: {
create: {
provider: OauthProviders[provider.toUpperCase()],
token: oauth_resp.access_token,
refresh: oauth_resp.refresh_token || null,
username: oauth_resp.username,
oauthId: oauth_resp.user_id as string,
},
},
avatar: oauth_resp.avatar,
},
});
} catch (e) {
if (e.code === 'P2002') {
logger.debug(`account already linked with ${provider}`);
return oauthError('This account is already linked with another user.');
} else throw e;
}
res.setUserCookie(user.id);
logger.info(`User ${user.username} (${user.id}) linked account via oauth(${provider})`);
@@ -113,6 +149,7 @@ export const withOAuth =
token: oauth_resp.access_token,
refresh: oauth_resp.refresh_token || null,
username: oauth_resp.username,
oauthId: oauth_resp.user_id as string,
},
});
@@ -120,7 +157,7 @@ export const withOAuth =
logger.info(`User ${user.username} (${user.id}) logged in via oauth(${provider})`);
return res.redirect('/dashboard');
} else if (existing && existingOauth) {
} else if ((existingOauth && existingOauth.fallback) || existingOauth) {
await prisma.oAuth.update({
where: {
id: existingOauth!.id,
@@ -129,39 +166,51 @@ export const withOAuth =
token: oauth_resp.access_token,
refresh: oauth_resp.refresh_token || null,
username: oauth_resp.username,
oauthId: oauth_resp.user_id as string,
},
});
res.setUserCookie(existing.id);
Logger.get('user').info(`User ${existing.username} (${existing.id}) logged in via oauth(${provider})`);
res.setUserCookie(existingOauth.userId);
Logger.get('user').info(
`User ${existingOauth.username} (${existingOauth.id}) logged in via oauth(${provider})`
);
return res.redirect('/dashboard');
} else if (existing) {
return oauthError(`Username "${oauth_resp.username}" is already taken, unable to create account.`);
}
} else if (config.features.oauth_login_only) {
return oauthError('Login only mode is enabled, unable to create account.');
} else if (existingUser)
return oauthError(`Username ${oauth_resp.username} is already taken, unable to create account.`);
logger.debug('creating new user via oauth');
const nuser = await prisma.user.create({
data: {
username: oauth_resp.username,
token: createToken(),
oauth: {
create: {
provider: OauthProviders[provider.toUpperCase()],
token: oauth_resp.access_token,
refresh: oauth_resp.refresh_token || null,
username: oauth_resp.username,
try {
const nuser = await prisma.user.create({
data: {
username: oauth_resp.username,
token: createToken(),
oauth: {
create: {
provider: OauthProviders[provider.toUpperCase()],
token: oauth_resp.access_token,
refresh: oauth_resp.refresh_token || null,
username: oauth_resp.username,
oauthId: oauth_resp.user_id as string,
},
},
avatar: oauth_resp.avatar,
},
avatar: oauth_resp.avatar,
},
});
});
logger.debug(`created user ${JSON.stringify(nuser)} via oauth(${provider})`);
logger.info(`Created user ${nuser.username} via oauth(${provider})`);
logger.debug(`created user ${JSON.stringify(nuser)} via oauth(${provider})`);
logger.info(`Created user ${nuser.username} via oauth(${provider})`);
res.setUserCookie(nuser.id);
logger.info(`User ${nuser.username} (${nuser.id}) logged in via oauth(${provider})`);
res.setUserCookie(nuser.id);
logger.info(`User ${nuser.username} (${nuser.id}) logged in via oauth(${provider})`);
return res.redirect('/dashboard');
return res.redirect('/dashboard');
} catch (e) {
if (e.code === 'P2002') {
logger.debug(`account already linked with ${provider}`);
return oauthError('This account is already linked with another user.');
} else throw e;
}
};
+14 -3
View File
@@ -18,9 +18,19 @@ export interface NextApiFile {
size: number;
}
export interface UserExtended extends User {
export interface UserOauth extends User {
oauth: OAuth[];
}
export type UserExtended = UserOauth & {
embed: UserEmbed;
};
export interface UserEmbed {
title?: string;
siteName?: string;
description?: string;
color?: string;
}
export type NextApiReq = NextApiRequest & {
user: () => Promise<UserExtended | null>;
@@ -166,7 +176,7 @@ export const withZipline =
include: { oauth: true },
});
if (user) return user;
if (user) return user as UserExtended;
}
const userId = req.getCookie('user');
@@ -182,8 +192,9 @@ export const withZipline =
});
if (!user) return null;
return user;
return user as UserExtended;
} catch (e) {
Logger.get('withZipline').debug(e.message);
if (e.code && e.code === 'ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH') {
req.cleanCookie('user');
return null;
+4
View File
@@ -5,3 +5,7 @@ if (!global.prisma) {
}
export default global.prisma as PrismaClient;
declare global {
var prisma: PrismaClient;
}
+25 -11
View File
@@ -2,9 +2,9 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import queryClient from './client';
export type UserFilesResponse = {
created_at: string;
expires_at?: string;
file: string;
createdAt: Date;
expiresAt?: Date;
name: string;
mimetype: string;
id: string;
favorite: boolean;
@@ -23,18 +23,31 @@ export const useFiles = (query: { [key: string]: string } = {}) => {
? data
: data.map((x) => ({
...x,
created_at: new Date(x.created_at).toLocaleString(),
createdAt: new Date(x.createdAt),
expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
}))
);
});
};
export const usePaginatedFiles = (page?: number, filter: string = 'media', favorite = null) => {
const queryBuilder = new URLSearchParams({
page: Number(page || '1').toString(),
filter,
...(favorite !== null && { favorite: favorite.toString() }),
});
const queryString = queryBuilder.toString();
export const usePaginatedFiles = (query: { [key: string]: string } = {}) => {
query['paged'] = 'true';
const data = useFiles(query) as ReturnType<typeof useQuery> & {
data: UserFilesResponse[][];
};
return data;
return useQuery<UserFilesResponse[]>(['files', queryString], async () => {
return fetch('/api/user/paged?' + queryString)
.then((res) => res.json() as Promise<UserFilesResponse[]>)
.then((data) =>
data.map((x) => ({
...x,
createdAt: new Date(x.createdAt),
expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
}))
);
});
};
export const useRecent = (filter?: string) => {
@@ -44,7 +57,8 @@ export const useRecent = (filter?: string) => {
.then((data) =>
data.map((x) => ({
...x,
created_at: new Date(x.created_at).toLocaleString(),
createdAt: new Date(x.createdAt),
expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
}))
);
});
+46
View File
@@ -0,0 +1,46 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import queryClient from './client';
import { UserFilesResponse } from './files';
export type UserFoldersResponse = {
id: number;
name: string;
userId: number;
createdAt: string;
updatedAt: string;
public: boolean;
files?: UserFilesResponse[];
};
export const useFolders = (query: { [key: string]: string } = {}) => {
const queryBuilder = new URLSearchParams(query);
const queryString = queryBuilder.toString();
return useQuery<UserFoldersResponse[]>(['folders', queryString], async () => {
return fetch('/api/user/folders?' + queryString)
.then((res) => res.json() as Promise<UserFoldersResponse[]>)
.then((data) =>
data.map((x) => ({
...x,
createdAt: new Date(x.createdAt).toLocaleString(),
updatedAt: new Date(x.updatedAt).toLocaleString(),
}))
);
});
};
export const useFolder = (id: string, withFiles: boolean = false) => {
return useQuery<UserFoldersResponse>(['folder', id], async () => {
return fetch('/api/user/folders/' + id + (withFiles ? '?files=true' : ''))
.then((res) => res.json() as Promise<UserFoldersResponse>)
.then((data) => ({
...data,
createdAt: new Date(data.createdAt).toLocaleString(),
updatedAt: new Date(data.updatedAt).toLocaleString(),
}));
});
};
export function invalidateFolders() {
return queryClient.invalidateQueries(['folders']);
}
+1 -1
View File
@@ -6,7 +6,7 @@ type StatsTypesCount = {
};
export type Stats = {
created_at: string;
createdAt: string;
id: number;
data: {
count: number;
+1 -1
View File
@@ -2,7 +2,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import queryClient from './client';
export type URLResponse = {
created_at: string;
createdAt: string;
destination: string;
id: string;
url: string;
+12 -1
View File
@@ -1,7 +1,18 @@
import { useQuery } from '@tanstack/react-query';
export type VersionResponse = {
isUpstream: boolean;
update?: boolean;
updateToType?: keyof VersionResponse['versions'];
versions: {
stable: string;
upstream: string;
current: string;
};
};
export const useVersion = () => {
return useQuery<{ local: string; upstream: string }>(
return useQuery<VersionResponse>(
['version'],
async () => {
return fetch('/api/version').then((res) => res.json());

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