feat(v3.4.0): switch from Material-UI to Mantine! (#127)
This commit is contained in:
parent
4d9a22e82c
commit
16d2014bfb
61 changed files with 2317 additions and 3463 deletions
46
docker-compose.dev.yml
Normal file
46
docker-compose.dev.yml
Normal file
|
@ -0,0 +1,46 @@
|
|||
version: '3'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DATABASE=postgres
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
zipline:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- '3000:3000'
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- SECURE=false
|
||||
- SECRET=changethis
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
|
||||
- UPLOADER_ROUTE=/u
|
||||
- UPLOADER_EMBED_ROUTE=/a
|
||||
- UPLOADER_LENGTH=6
|
||||
- UPLOADER_DIRECTORY=./uploads
|
||||
- UPLOADER_ADMIN_LIMIT=104900000
|
||||
- UPLOADER_USER_LIMIT=104900000
|
||||
- UPLOADER_DISABLED_EXTS=
|
||||
- URLS_ROUTE=/go
|
||||
- URLS_LENGTH=6
|
||||
volumes:
|
||||
- '$PWD/uploads:/zipline/uploads'
|
||||
- '$PWD/public:/zipline/public'
|
||||
depends_on:
|
||||
- 'postgres'
|
||||
|
||||
volumes:
|
||||
pg_data:
|
|
@ -1,3 +1,11 @@
|
|||
module.exports = {
|
||||
reactStrictMode: true,
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/',
|
||||
destination: '/dashboard',
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
30
package.json
30
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "zip3",
|
||||
"version": "3.3.2",
|
||||
"version": "3.4.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development node server",
|
||||
|
@ -10,15 +10,21 @@
|
|||
"migrate:dev": "prisma migrate dev --create-only",
|
||||
"start": "node server",
|
||||
"lint": "next lint",
|
||||
"seed": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only prisma/seed.ts"
|
||||
"seed": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only prisma/seed.ts",
|
||||
"docker:run": "docker-compose up -d",
|
||||
"docker:down": "docker-compose down",
|
||||
"docker:build-dev": "docker-compose --file docker-compose.dev.yml up --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.4.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@iarna/toml": "2.2.5",
|
||||
"@mui/icons-material": "^5.0.0",
|
||||
"@mui/material": "^5.0.2",
|
||||
"@mui/styles": "^5.0.1",
|
||||
"@mantine/core": "^3.6.9",
|
||||
"@mantine/dropzone": "^3.6.9",
|
||||
"@mantine/hooks": "^3.6.9",
|
||||
"@mantine/modals": "^3.6.9",
|
||||
"@mantine/next": "^3.6.9",
|
||||
"@mantine/notifications": "^3.6.9",
|
||||
"@mantine/prism": "^3.6.11",
|
||||
"@modulz/radix-icons": "^4.0.0",
|
||||
"@prisma/client": "^3.9.2",
|
||||
"@prisma/migrate": "^3.9.2",
|
||||
"@prisma/sdk": "^3.9.2",
|
||||
|
@ -26,17 +32,14 @@
|
|||
"argon2": "^0.28.2",
|
||||
"colorette": "^1.2.2",
|
||||
"cookie": "^0.4.1",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"fecha": "^4.2.1",
|
||||
"formik": "^2.2.9",
|
||||
"multer": "^1.4.2",
|
||||
"next": "^12.1.0",
|
||||
"prisma": "^3.9.2",
|
||||
"react": "17.0.2",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "17.0.2",
|
||||
"react-dropzone": "^11.3.2",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-redux": "^7.2.4",
|
||||
"react-table": "^7.7.0",
|
||||
"redux": "^4.1.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"uuid": "^8.3.2",
|
||||
|
@ -46,6 +49,7 @@
|
|||
"@types/cookie": "^0.4.0",
|
||||
"@types/multer": "^1.4.6",
|
||||
"@types/node": "^15.12.2",
|
||||
"babel-plugin-import": "^1.13.3",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-next": "11.0.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
|
|
|
@ -13,8 +13,7 @@ model User {
|
|||
password String
|
||||
token String
|
||||
administrator Boolean @default(false)
|
||||
systemTheme String @default("dark_blue")
|
||||
customTheme Theme?
|
||||
systemTheme String @default("system")
|
||||
embedTitle String?
|
||||
embedColor String @default("#2f3136")
|
||||
embedSiteName String? @default("{image.file} • {user.name}")
|
||||
|
@ -23,21 +22,6 @@ model User {
|
|||
urls Url[]
|
||||
}
|
||||
|
||||
model Theme {
|
||||
id Int @id @default(autoincrement())
|
||||
type String
|
||||
primary String
|
||||
secondary String
|
||||
error String
|
||||
warning String
|
||||
info String
|
||||
border String
|
||||
mainBackground String
|
||||
paperBackground String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
}
|
||||
|
||||
enum ImageFormat {
|
||||
UUID
|
||||
DATE
|
||||
|
|
38
scripts/exts.js
Normal file
38
scripts/exts.js
Normal file
|
@ -0,0 +1,38 @@
|
|||
// https://github.com/toptal/haste-server/blob/master/static/application.js#L167-L174
|
||||
// Popular extension map
|
||||
module.exports = {
|
||||
rb: 'ruby',
|
||||
py: 'python',
|
||||
pl: 'perl',
|
||||
php: 'php',
|
||||
scala: 'scala',
|
||||
go: 'go',
|
||||
xml: 'xml',
|
||||
html: 'xml',
|
||||
htm: 'xml',
|
||||
css: 'css',
|
||||
js: 'javascript',
|
||||
json: 'json',
|
||||
vbs: 'vbscript',
|
||||
lua: 'lua',
|
||||
pas: 'delphi',
|
||||
java: 'java',
|
||||
cpp: 'cpp',
|
||||
cc: 'cpp',
|
||||
m: 'objectivec',
|
||||
vala: 'vala',
|
||||
sql: 'sql',
|
||||
sm: 'smalltalk',
|
||||
lisp: 'lisp',
|
||||
ini: 'ini',
|
||||
diff: 'diff',
|
||||
bash: 'bash',
|
||||
sh: 'bash',
|
||||
tex: 'tex',
|
||||
erl: 'erlang',
|
||||
hs: 'haskell',
|
||||
md: 'markdown',
|
||||
txt: '',
|
||||
coffee: 'coffee',
|
||||
swift: 'swift',
|
||||
};
|
|
@ -1,18 +1,16 @@
|
|||
const next = require('next').default;
|
||||
const defaultConfig = require('next/dist/server/config-shared').defaultConfig;
|
||||
const { createServer } = require('http');
|
||||
const { stat, mkdir } = require('fs/promises');
|
||||
const { mkdir } = require('fs/promises');
|
||||
const { extname } = require('path');
|
||||
const validateConfig = require('./validateConfig');
|
||||
const Logger = require('../src/lib/logger');
|
||||
const readConfig = require('../src/lib/readConfig');
|
||||
const mimes = require('../scripts/mimes');
|
||||
const { log, getStats, shouldUseYarn, getFile, migrations } = require('./util');
|
||||
const { log, getStats, getFile, migrations } = require('./util');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { version } = require('../package.json');
|
||||
const nextConfig = require('../next.config');
|
||||
const exts = require('../scripts/exts');
|
||||
const serverLog = Logger.get('server');
|
||||
const webLog = Logger.get('web');
|
||||
|
||||
serverLog.info(`starting zipline@${version} server`);
|
||||
|
||||
|
@ -42,7 +40,6 @@ async function run() {
|
|||
quiet: !dev,
|
||||
hostname: config.core.host,
|
||||
port: config.core.port,
|
||||
conf: Object.assign(defaultConfig, nextConfig),
|
||||
});
|
||||
|
||||
await app.prepare();
|
||||
|
@ -51,10 +48,10 @@ async function run() {
|
|||
const prisma = new PrismaClient();
|
||||
|
||||
const srv = createServer(async (req, res) => {
|
||||
if (req.url.startsWith('/r')) {
|
||||
const parts = req.url.split('/');
|
||||
if (!parts[2] || parts[2] === '') return;
|
||||
|
||||
if (req.url.startsWith('/r')) {
|
||||
let image = await prisma.image.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
|
@ -70,19 +67,17 @@ async function run() {
|
|||
},
|
||||
});
|
||||
|
||||
image && await prisma.image.update({
|
||||
where: { id: image.id },
|
||||
data: { views: { increment: 1 } },
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
const data = await getFile(config.uploader.directory, parts[2]);
|
||||
if (!data) return app.render404(req, res);
|
||||
|
||||
if (!image) { // raw image
|
||||
const mimetype = mimes[extname(parts[2])] ?? 'application/octet-stream';
|
||||
res.setHeader('Content-Type', mimetype);
|
||||
res.end(data);
|
||||
} else { // raw image & update db
|
||||
} else {
|
||||
const data = await getFile(config.uploader.directory, image.file);
|
||||
if (!data) return app.render404(req, res);
|
||||
|
||||
await prisma.image.update({
|
||||
where: { id: image.id },
|
||||
data: { views: { increment: 1 } },
|
||||
|
@ -91,8 +86,8 @@ async function run() {
|
|||
res.end(data);
|
||||
}
|
||||
} else if (req.url.startsWith(config.uploader.route)) {
|
||||
const data = await getFile(config.uploader.directory, parts[2]);
|
||||
if (!data) return app.render404(req, res);
|
||||
const parts = req.url.split('/');
|
||||
if (!parts[2] || parts[2] === '') return;
|
||||
|
||||
let image = await prisma.image.findFirst({
|
||||
where: {
|
||||
|
@ -110,18 +105,25 @@ async function run() {
|
|||
},
|
||||
});
|
||||
|
||||
image && await prisma.image.update({
|
||||
where: { id: image.id },
|
||||
data: { views: { increment: 1 } },
|
||||
});
|
||||
if (!image) {
|
||||
const data = await getFile(config.uploader.directory, parts[2]);
|
||||
if (!data) return app.render404(req, res);
|
||||
|
||||
if (!image) { // raw image
|
||||
const mimetype = mimes[extname(parts[2])] ?? 'application/octet-stream';
|
||||
res.setHeader('Content-Type', mimetype);
|
||||
res.end(data);
|
||||
} else if (image.embed) { // embed image
|
||||
} else if (image.embed) {
|
||||
handle(req, res);
|
||||
} else { // raw image fallback
|
||||
} else {
|
||||
const ext = image.file.split('.').pop();
|
||||
if (Object.keys(exts).includes(ext)) return handle(req, res);
|
||||
const data = await getFile(config.uploader.directory, image.file);
|
||||
if (!data) return app.render404(req, res);
|
||||
|
||||
await prisma.image.update({
|
||||
where: { id: image.id },
|
||||
data: { views: { increment: 1 } },
|
||||
});
|
||||
res.setHeader('Content-Type', image.mimetype);
|
||||
res.end(data);
|
||||
}
|
||||
|
@ -137,6 +139,7 @@ async function run() {
|
|||
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
srv.on('listening', () => {
|
||||
serverLog.info(`listening on ${config.core.host}:${config.core.port}`);
|
||||
});
|
||||
|
|
|
@ -7,7 +7,7 @@ const validator = object({
|
|||
host: string().default('0.0.0.0'),
|
||||
port: number().default(3000),
|
||||
database_url: string().required(),
|
||||
logger: boolean().default(true),
|
||||
logger: boolean().default(false),
|
||||
stats_interval: number().default(1800),
|
||||
}).required(),
|
||||
uploader: object({
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Snackbar, Alert as MuiAlert } from '@mui/material';
|
||||
|
||||
export default function Alert({ open, setOpen, severity, message }) {
|
||||
return (
|
||||
<Snackbar open={open} autoHideDuration={6000} anchorOrigin={{ vertical: 'top', horizontal: 'center' }} onClose={() => setOpen(false)}>
|
||||
<MuiAlert severity={severity} sx={{ width: '100%' }}>
|
||||
{message}
|
||||
</MuiAlert>
|
||||
</Snackbar>
|
||||
);
|
||||
}
|
|
@ -1,16 +1,8 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Backdrop as MuiBackdrop,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
|
||||
export default function Backdrop({ open }) {
|
||||
return (
|
||||
<MuiBackdrop
|
||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||
open={open}
|
||||
>
|
||||
<CircularProgress color='inherit' />
|
||||
</MuiBackdrop>
|
||||
<LoadingOverlay visible={open} />
|
||||
);
|
||||
}
|
|
@ -1,19 +1,16 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Card as MuiCard,
|
||||
CardContent,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
Card as MCard,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
|
||||
export default function Card(props) {
|
||||
const { name, children, ...other } = props;
|
||||
|
||||
return (
|
||||
<MuiCard sx={{ minWidth: '100%' }} {...other}>
|
||||
<CardContent>
|
||||
<Typography variant='h3'>{name}</Typography>
|
||||
<MCard padding='md' shadow='sm' {...other}>
|
||||
<Title order={2}>{name}</Title>
|
||||
{children}
|
||||
</CardContent>
|
||||
</MuiCard>
|
||||
</MCard>
|
||||
);
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
export default function CenteredBox({ children, ...other }) {
|
||||
return (
|
||||
<Box
|
||||
justifyContent='center'
|
||||
display='flex'
|
||||
alignItems='center'
|
||||
{...other}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
|
@ -1,83 +1,94 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardMedia,
|
||||
CardActionArea,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
} from '@mui/material';
|
||||
import AudioIcon from '@mui/icons-material/Audiotrack';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { Button, Card, Group, Image as MImage, Modal, Title } from '@mantine/core';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { CopyIcon, Cross1Icon, StarIcon, TrashIcon } from '@modulz/radix-icons';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
|
||||
export default function Image({ image, updateImages }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [t] = useState(image.mimetype.split('/')[0]);
|
||||
const notif = useNotifications();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
|
||||
if (!res.error) updateImages(true);
|
||||
if (!res.error) {
|
||||
updateImages(true);
|
||||
notif.showNotification({
|
||||
title: 'Image Deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <TrashIcon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'Failed to delete image',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
copy(`${window.location.protocol}//${window.location.host}${image.url}`);
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${image.url}`);
|
||||
setOpen(false);
|
||||
notif.showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
const data = await useFetch('/api/user/files', 'PATCH', { id: image.id, favorite: !image.favorite });
|
||||
if (!data.error) updateImages(true);
|
||||
notif.showNotification({
|
||||
title: 'Image is now ' + (!image.favorite ? 'favorited' : 'unfavorited'),
|
||||
message: '',
|
||||
icon: <StarIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const Type = (props) => {
|
||||
return {
|
||||
'video': <video controls {...props} />,
|
||||
// eslint-disable-next-line jsx-a11y/alt-text
|
||||
'image': <img {...props} />,
|
||||
'image': <MImage {...props} />,
|
||||
'audio': <audio controls {...props} />,
|
||||
}[t];
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card sx={{ maxWidth: '100%' }}>
|
||||
<CardActionArea sx={t === 'audio' ? { justifyContent: 'center', display: 'flex', alignItems: 'center' } : {}}>
|
||||
<CardMedia
|
||||
sx={{ height: 320, fontSize: 70, width: '100%' }}
|
||||
image={image.url}
|
||||
title={image.file}
|
||||
component={t === 'audio' ? AudioIcon : t} // this is done because audio without controls is hidden
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Title>{image.file}</Title>}
|
||||
>
|
||||
<Type
|
||||
src={image.url}
|
||||
alt={image.file}
|
||||
/>
|
||||
<Group position='right' mt={22}>
|
||||
<Button onClick={handleCopy}>Copy</Button>
|
||||
<Button onClick={handleDelete}>Delete</Button>
|
||||
<Button onClick={handleFavorite}>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
|
||||
<Card.Section>
|
||||
<Type
|
||||
sx={{ maxHeight: 320, fontSize: 70, width: '100%', cursor: 'pointer' }}
|
||||
style={{ maxHeight: 320, fontSize: 70, width: '100%', cursor: 'pointer' }}
|
||||
src={image.url}
|
||||
alt={image.file}
|
||||
onClick={() => setOpen(true)}
|
||||
/>
|
||||
</CardActionArea>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<DialogTitle id='alert-dialog-title'>
|
||||
{image.file}
|
||||
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Type
|
||||
style={{ width: '100%' }}
|
||||
src={image.url}
|
||||
alt={image.url}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleDelete} color='inherit'>Delete</Button>
|
||||
<Button onClick={handleCopy} color='inherit'>Copy URL</Button>
|
||||
<Button onClick={handleFavorite} color='inherit'>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
159
src/components/ImagesTable.tsx
Normal file
159
src/components/ImagesTable.tsx
Normal file
|
@ -0,0 +1,159 @@
|
|||
/* eslint-disable react/jsx-key */
|
||||
/* eslint-disable react/display-name */
|
||||
// Code taken from https://codesandbox.io/s/eojw8 and is modified a bit (the entire src/components/table directory)
|
||||
import React from 'react';
|
||||
import {
|
||||
usePagination,
|
||||
useTable,
|
||||
} from 'react-table';
|
||||
import {
|
||||
ActionIcon,
|
||||
Checkbox,
|
||||
createStyles,
|
||||
Divider,
|
||||
Group,
|
||||
Pagination,
|
||||
Select,
|
||||
Table,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
CopyIcon,
|
||||
EnterIcon,
|
||||
TrashIcon,
|
||||
} from '@modulz/radix-icons';
|
||||
|
||||
const pageSizeOptions = ['10', '25', '50'];
|
||||
|
||||
const useStyles = createStyles((t) => ({
|
||||
root: { height: '100%', display: 'block', marginTop: 10 },
|
||||
tableContainer: {
|
||||
display: 'block',
|
||||
overflow: 'auto',
|
||||
'& > table': {
|
||||
'& > thead': { backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0], zIndex: 1 },
|
||||
'& > thead > tr > th': { padding: t.spacing.md },
|
||||
'& > tbody > tr > td': { padding: t.spacing.md },
|
||||
},
|
||||
borderRadius: 6,
|
||||
},
|
||||
stickHeader: { top: 0, position: 'sticky' },
|
||||
disableSortIcon: { color: t.colors.gray[5] },
|
||||
sortDirectionIcon: { transition: 'transform 200ms ease' },
|
||||
}));
|
||||
|
||||
export default function ImagesTable({
|
||||
columns,
|
||||
data = [],
|
||||
serverSideDataSource = false,
|
||||
initialPageSize = 10,
|
||||
initialPageIndex = 0,
|
||||
pageCount = 0,
|
||||
total = 0,
|
||||
deleteImage, copyImage, viewImage,
|
||||
}) {
|
||||
const { classes } = useStyles();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const tableOptions = useTable(
|
||||
{
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
initialState: { pageSize: initialPageSize, pageIndex: initialPageIndex },
|
||||
},
|
||||
usePagination
|
||||
);
|
||||
|
||||
const {
|
||||
getTableProps, getTableBodyProps, headerGroups, rows, prepareRow, page, gotoPage, setPageSize, state: { pageIndex, pageSize },
|
||||
} = tableOptions;
|
||||
|
||||
const getPageRecordInfo = () => {
|
||||
const firstRowNum = pageIndex * pageSize + 1;
|
||||
const totalRows = serverSideDataSource ? total : rows.length;
|
||||
|
||||
const currLastRowNum = (pageIndex + 1) * pageSize;
|
||||
let lastRowNum = currLastRowNum < totalRows ? currLastRowNum : totalRows;
|
||||
return `${firstRowNum} - ${lastRowNum} of ${totalRows}`;
|
||||
};
|
||||
|
||||
const getPageCount = () => {
|
||||
const totalRows = serverSideDataSource ? total : rows.length;
|
||||
return Math.ceil(totalRows / pageSize);
|
||||
};
|
||||
|
||||
const handlePageChange = (pageNum) => gotoPage(pageNum - 1);
|
||||
|
||||
const renderHeader = () => headerGroups.map(hg => (
|
||||
<tr {...hg.getHeaderGroupProps()}>
|
||||
{hg.headers.map(column => (
|
||||
<th {...column.getHeaderProps()}>
|
||||
<Group noWrap position={column.align || 'apart'}>
|
||||
<div>{column.render('Header')}</div>
|
||||
</Group>
|
||||
</th>
|
||||
))}
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
));
|
||||
|
||||
const renderRow = rows => rows.map(row => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
<tr {...row.getRowProps()}>
|
||||
{row.cells.map(cell => (
|
||||
<td align={cell.column.align || 'left'} {...cell.getCellProps()}>
|
||||
{cell.render('Cell')}
|
||||
</td>
|
||||
))}
|
||||
<td align='right'>
|
||||
<Group noWrap>
|
||||
<ActionIcon color='red' variant='outline' onClick={() => deleteImage(row)}><TrashIcon /></ActionIcon>
|
||||
<ActionIcon color='primary' variant='outline' onClick={() => copyImage(row)}><CopyIcon /></ActionIcon>
|
||||
<ActionIcon color='green' variant='outline' onClick={() => viewImage(row)}><EnterIcon /></ActionIcon>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div
|
||||
className={classes.tableContainer}
|
||||
style={{ height: 'calc(100% - 44px)' }}
|
||||
>
|
||||
<Table {...getTableProps()}>
|
||||
<thead style={{ backgroundColor: theme.other.hover }}>
|
||||
{renderHeader()}
|
||||
</thead>
|
||||
|
||||
<tbody {...getTableBodyProps()}>
|
||||
{renderRow(page)}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<Divider mb='md' variant='dotted' />
|
||||
<Group position='left'>
|
||||
<Text size='sm'>Rows per page: </Text>
|
||||
<Select
|
||||
style={{ width: '72px' }}
|
||||
variant='filled'
|
||||
data={pageSizeOptions}
|
||||
value={pageSize + ''}
|
||||
onChange={pageSize => setPageSize(Number(pageSize))} />
|
||||
<Divider orientation='vertical' />
|
||||
|
||||
<Text size='sm'>{getPageRecordInfo()}</Text>
|
||||
<Divider orientation='vertical' />
|
||||
|
||||
<Pagination
|
||||
page={pageIndex + 1}
|
||||
total={getPageCount()}
|
||||
onChange={handlePageChange} />
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,49 +1,67 @@
|
|||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
AppBar,
|
||||
Box,
|
||||
Divider,
|
||||
Drawer,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Menu as MenuIcon,
|
||||
Home as HomeIcon,
|
||||
AccountCircle as AccountIcon,
|
||||
Folder as FolderIcon,
|
||||
Upload as UploadIcon,
|
||||
ContentCopy as CopyIcon,
|
||||
Autorenew as ResetIcon,
|
||||
Logout as LogoutIcon,
|
||||
PeopleAlt as UsersIcon,
|
||||
Brush as BrushIcon,
|
||||
Link as URLIcon,
|
||||
} from '@mui/icons-material';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import Backdrop from './Backdrop';
|
||||
import { friendlyThemeName, themes } from 'components/Theming';
|
||||
import Select from 'components/input/Select';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useStoreDispatch } from 'lib/redux/store';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { CheckIcon, CopyIcon, Cross1Icon, FileIcon, GearIcon, HomeIcon, Link1Icon, ResetIcon, UploadIcon, PinRightIcon, PersonIcon, Pencil1Icon, MixerHorizontalIcon } from '@modulz/radix-icons';
|
||||
import { AppShell, Burger, Divider, Group, Header, MediaQuery, Navbar, Paper, Popover, ScrollArea, Select, Text, ThemeIcon, Title, UnstyledButton, useMantineTheme, Box } from '@mantine/core';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { friendlyThemeName, themes } from './Theming';
|
||||
|
||||
function MenuItemLink(props) {
|
||||
return (
|
||||
<Link href={props.href} passHref>
|
||||
<MenuItem {...props} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem(props) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
sx={theme => ({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: 5,
|
||||
borderRadius: theme.radius.sm,
|
||||
color: props.color
|
||||
? theme.fn.themeColor(props.color, theme.colorScheme === 'dark' ? 5 : 7)
|
||||
: theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[0]
|
||||
: theme.black,
|
||||
'&:hover': {
|
||||
backgroundColor: props.color
|
||||
? theme.fn.rgba(
|
||||
theme.fn.themeColor(props.color, theme.colorScheme === 'dark' ? 9 : 0),
|
||||
theme.colorScheme === 'dark' ? 0.2 : 1
|
||||
)
|
||||
: theme.colorScheme === 'dark'
|
||||
? theme.fn.rgba(theme.colors.dark[3], 0.35)
|
||||
: theme.colors.gray[0],
|
||||
},
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
<Group noWrap>
|
||||
<Box sx={theme => ({
|
||||
marginRight: theme.spacing.xs / 4,
|
||||
paddingLeft: theme.spacing.xs / 2,
|
||||
|
||||
'& *': {
|
||||
display: 'block',
|
||||
},
|
||||
})}>
|
||||
{props.icon}
|
||||
</Box>
|
||||
<Text size='sm'>{props.children}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
const items = [
|
||||
{
|
||||
|
@ -52,12 +70,17 @@ const items = [
|
|||
link: '/dashboard',
|
||||
},
|
||||
{
|
||||
icon: <FolderIcon />,
|
||||
icon: <FileIcon />,
|
||||
text: 'Files',
|
||||
link: '/dashboard/files',
|
||||
},
|
||||
{
|
||||
icon: <URLIcon />,
|
||||
icon: <MixerHorizontalIcon />,
|
||||
text: 'Stats',
|
||||
link: '/dashboard/stats',
|
||||
},
|
||||
{
|
||||
icon: <Link1Icon />,
|
||||
text: 'URLs',
|
||||
link: '/dashboard/urls',
|
||||
},
|
||||
|
@ -68,344 +91,239 @@ const items = [
|
|||
},
|
||||
];
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
function CopyTokenDialog({ open, setOpen, token }) {
|
||||
const handleCopyToken = () => {
|
||||
copy(token);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<DialogTitle id='copy-dialog-title'>
|
||||
Copy Token
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id='copy-dialog-description'>
|
||||
Make sure you don't share this token with anyone as they will be able to upload files on your behalf.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
|
||||
<Button onClick={handleCopyToken} color='inherit'>
|
||||
Copy
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetTokenDialog({ open, setOpen, setToken }) {
|
||||
const handleResetToken = async () => {
|
||||
const a = await useFetch('/api/user/token', 'PATCH');
|
||||
if (a.success) setToken(a.success);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<DialogTitle id='reset-dialog-title'>
|
||||
Reset Token
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id='reset-dialog-description'>
|
||||
Once you reset your token, you will have to update any uploaders to use this new token.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
|
||||
<Button onClick={handleResetToken} color='inherit'>
|
||||
Reset
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout({ children, user, loading, noPaper }) {
|
||||
const [systemTheme, setSystemTheme] = useState(user.systemTheme || 'dark_blue');
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [copyOpen, setCopyOpen] = useState(false);
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
export default function Layout({ children, user }) {
|
||||
const [token, setToken] = useState(user?.token);
|
||||
const [systemTheme, setSystemTheme] = useState(user.systemTheme ?? 'system');
|
||||
const [opened, setOpened] = useState(false); // navigation open
|
||||
const [open, setOpen] = useState(false); // manage acc dropdown
|
||||
const router = useRouter();
|
||||
const dispatch = useStoreDispatch();
|
||||
const theme = useMantineTheme();
|
||||
const modals = useModals();
|
||||
const notif = useNotifications();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = e => setAnchorEl(e.currentTarget);
|
||||
const handleClose = (cmd: 'copy' | 'reset') => () => {
|
||||
switch (cmd) {
|
||||
case 'copy':
|
||||
setCopyOpen(true);
|
||||
break;
|
||||
case 'reset':
|
||||
setResetOpen(true);
|
||||
break;
|
||||
}
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleUpdateTheme = async event => {
|
||||
const handleUpdateTheme = async value => {
|
||||
const newUser = await useFetch('/api/user', 'PATCH', {
|
||||
systemTheme: event.target.value || 'dark_blue',
|
||||
systemTheme: value || 'dark_blue',
|
||||
});
|
||||
|
||||
setSystemTheme(newUser.systemTheme);
|
||||
dispatch(updateUser(newUser));
|
||||
|
||||
router.replace(router.pathname);
|
||||
|
||||
notif.showNotification({
|
||||
title: `Theme changed to ${friendlyThemeName[value]}`,
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <Pencil1Icon />,
|
||||
});
|
||||
};
|
||||
|
||||
const drawer = (
|
||||
<div>
|
||||
<CopyTokenDialog open={copyOpen} setOpen={setCopyOpen} token={token} />
|
||||
<ResetTokenDialog open={resetOpen} setOpen={setResetOpen} setToken={setToken} />
|
||||
<Toolbar
|
||||
sx={{
|
||||
width: { xs: drawerWidth },
|
||||
}}
|
||||
>
|
||||
<AppBar
|
||||
position='fixed'
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderBottomColor: t => t.palette.divider,
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color='inherit'
|
||||
aria-label='open drawer'
|
||||
edge='start'
|
||||
onClick={() => setMobileOpen(true)}
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography
|
||||
variant='h5'
|
||||
noWrap
|
||||
component='div'
|
||||
>
|
||||
Zipline
|
||||
</Typography>
|
||||
{user && (
|
||||
<Box sx={{ marginLeft: 'auto' }}>
|
||||
<Button
|
||||
color='inherit'
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<AccountIcon />
|
||||
</Button>
|
||||
<Menu
|
||||
id='zipline-user-menu'
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose(null)}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
<MenuItem disableRipple>
|
||||
<Typography variant='h5'>
|
||||
<b>{user.username}</b>
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<Link href='/dashboard/manage' passHref>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<AccountIcon sx={{ mr: 2 }} /> Manage Account
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<MenuItem onClick={handleClose('copy')}>
|
||||
<CopyIcon sx={{ mr: 2 }} /> Copy Token
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleClose('reset')}>
|
||||
<ResetIcon sx={{ mr: 2 }} /> Reset Token
|
||||
</MenuItem>
|
||||
<Link href='/auth/logout' passHref>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<LogoutIcon sx={{ mr: 2 }} /> Logout
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<MenuItem>
|
||||
<BrushIcon sx={{ mr: 2 }} />
|
||||
<Select
|
||||
variant='standard'
|
||||
label='Theme'
|
||||
value={systemTheme}
|
||||
onChange={handleUpdateTheme}
|
||||
fullWidth
|
||||
>
|
||||
{Object.keys(themes).map(t => (
|
||||
<MenuItem value={t} key={t}>
|
||||
{friendlyThemeName[t]}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<List>
|
||||
{items.map((item, i) => (
|
||||
<Link key={i} href={item.link} passHref>
|
||||
<ListItem button>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItem>
|
||||
</Link>
|
||||
))}
|
||||
{user && user.administrator && (
|
||||
<Link href='/dashboard/users' passHref>
|
||||
<ListItem button>
|
||||
<ListItemIcon><UsersIcon /></ListItemIcon>
|
||||
<ListItemText primary='Users' />
|
||||
</ListItem>
|
||||
</Link>
|
||||
)}
|
||||
</List>
|
||||
const openResetToken = () => modals.openConfirmModal({
|
||||
title: 'Reset Token',
|
||||
children: (
|
||||
<Text size='sm'>
|
||||
Once you reset your token, you will have to update any uploaders to use this new token.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: 'Reset', cancel: 'Cancel' },
|
||||
onConfirm: async () => {
|
||||
const a = await useFetch('/api/user/token', 'PATCH');
|
||||
if (!a.success) {
|
||||
setToken(a.success);
|
||||
notif.showNotification({
|
||||
title: 'Token Reset Failed',
|
||||
message: a.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'Token Reset',
|
||||
message: 'Your token has been reset. You will need to update any uploaders to use this new token.',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
</div>
|
||||
);
|
||||
modals.closeAll();
|
||||
},
|
||||
});
|
||||
|
||||
const container = typeof window !== 'undefined' ? window.document.body : undefined;
|
||||
const openCopyToken = () => modals.openConfirmModal({
|
||||
title: 'Copy Token',
|
||||
children: (
|
||||
<Text size='sm'>
|
||||
Make sure you don't share this token with anyone as they will be able to upload files on your behalf.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: 'Copy', cancel: 'Cancel' },
|
||||
onConfirm: async () => {
|
||||
clipboard.copy(token);
|
||||
|
||||
notif.showNotification({
|
||||
title: 'Token Copied',
|
||||
message: 'Your token has been copied to your clipboard.',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
});
|
||||
|
||||
modals.closeAll();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<Backdrop open={loading} />
|
||||
<AppShell
|
||||
navbarOffsetBreakpoint='sm'
|
||||
fixed
|
||||
navbar={
|
||||
<Navbar
|
||||
padding='md'
|
||||
hiddenBreakpoint='sm'
|
||||
hidden={!opened}
|
||||
width={{ sm: 200, lg: 230 }}
|
||||
>
|
||||
<Navbar.Section
|
||||
grow
|
||||
component={ScrollArea}
|
||||
ml={-10}
|
||||
mr={-10}
|
||||
sx={{ paddingLeft: 10, paddingRight: 10 }}
|
||||
>
|
||||
{items.map(({ icon, text, link }) => (
|
||||
<Link href={link} key={text} passHref>
|
||||
<UnstyledButton
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: theme.spacing.xs,
|
||||
borderRadius: theme.radius.sm,
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.black,
|
||||
|
||||
<AppBar
|
||||
position='fixed'
|
||||
elevation={0}
|
||||
sx={{
|
||||
width: { sm: `calc(100% - ${drawerWidth}px)` },
|
||||
ml: { sm: `${drawerWidth}px` },
|
||||
'&:hover': {
|
||||
backgroundColor: theme.other.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color='inherit'
|
||||
aria-label='open drawer'
|
||||
edge='start'
|
||||
onClick={() => setMobileOpen(true)}
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography
|
||||
variant='h5'
|
||||
noWrap
|
||||
component='div'
|
||||
sx={{ display: { sm: 'none' } }}
|
||||
>
|
||||
Zipline
|
||||
</Typography>
|
||||
{user && (
|
||||
<Box sx={{ marginLeft: 'auto' }}>
|
||||
<Button
|
||||
color='inherit'
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<AccountIcon />
|
||||
</Button>
|
||||
<Menu
|
||||
id='zipline-user-menu'
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose(null)}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
<MenuItem disableRipple>
|
||||
<Typography variant='h5'>
|
||||
<b>{user.username}</b>
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<Link href='/dash/manage' passHref>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<AccountIcon sx={{ mr: 2 }} /> Manage Account
|
||||
</MenuItem>
|
||||
<Group>
|
||||
<ThemeIcon color='primary' variant='filled'>
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
|
||||
<Text size='lg'>{text}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Link>
|
||||
<MenuItem onClick={handleClose('copy')}>
|
||||
<CopyIcon sx={{ mr: 2 }} /> Copy Token
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleClose('reset')}>
|
||||
<ResetIcon sx={{ mr: 2 }} /> Reset Token
|
||||
</MenuItem>
|
||||
<Link href='/auth/logout' passHref>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<LogoutIcon sx={{ mr: 2 }} /> Logout
|
||||
</MenuItem>
|
||||
))}
|
||||
{user.administrator && (
|
||||
<Link href='/dashboard/users' passHref>
|
||||
<UnstyledButton
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: theme.spacing.xs,
|
||||
borderRadius: theme.radius.sm,
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.black,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.other.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group>
|
||||
<ThemeIcon color='primary' variant='filled'>
|
||||
<PersonIcon />
|
||||
</ThemeIcon>
|
||||
|
||||
<Text size='lg'>Users</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Link>
|
||||
</Menu>
|
||||
</Box>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Box
|
||||
component='nav'
|
||||
</Navbar.Section>
|
||||
</Navbar>
|
||||
}
|
||||
header={
|
||||
<Header height={70} padding='md'>
|
||||
<div style={{ display: 'flex', alignItems: 'center', height: '100%' }}>
|
||||
<MediaQuery largerThan='sm' styles={{ display: 'none' }}>
|
||||
<Burger
|
||||
opened={opened}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
size='sm'
|
||||
color={theme.colors.gray[6]}
|
||||
/>
|
||||
</MediaQuery>
|
||||
<Title sx={{ marginLeft: 12 }}>Zipline</Title>
|
||||
<Box sx={{ marginLeft: 'auto', marginRight: 0 }}>
|
||||
<Popover
|
||||
position='top'
|
||||
placement='end'
|
||||
spacing={4}
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
target={
|
||||
<UnstyledButton
|
||||
onClick={() => setOpen(!open)}
|
||||
sx={{
|
||||
width: { sm: drawerWidth },
|
||||
flexShrink: { sm: 0 },
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: theme.spacing.xs,
|
||||
borderRadius: theme.radius.sm,
|
||||
color: theme.other.color,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.other.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Drawer
|
||||
container={container}
|
||||
variant='temporary'
|
||||
onClose={() => setMobileOpen(false)}
|
||||
open={mobileOpen}
|
||||
elevation={0}
|
||||
ModalProps={{
|
||||
keepMounted: true,
|
||||
}}
|
||||
sx={{
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||
}}
|
||||
<Group>
|
||||
<ThemeIcon color='primary' variant='filled'>
|
||||
<GearIcon />
|
||||
</ThemeIcon>
|
||||
<Text>{user.username}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
<Drawer
|
||||
variant='permanent'
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||
}}
|
||||
open
|
||||
<Group direction='column' spacing={2}>
|
||||
<Text sx={{
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6],
|
||||
fontWeight: 500,
|
||||
fontSize: theme.fontSizes.xs,
|
||||
padding: `${theme.spacing.xs / 2}px ${theme.spacing.sm}px`,
|
||||
cursor: 'default',
|
||||
}}>User: {user.username}</Text>
|
||||
<MenuItemLink icon={<GearIcon />} href='/dashboard/manage'>Manage Account</MenuItemLink>
|
||||
<MenuItem icon={<CopyIcon />} onClick={() => {setOpen(false);openCopyToken();}}>Copy Token</MenuItem>
|
||||
<MenuItem icon={<ResetIcon />} onClick={() => {setOpen(false);openResetToken();}} color='red'>Reset Token</MenuItem>
|
||||
<MenuItemLink icon={<PinRightIcon />} href='/auth/logout' color='red'>Logout</MenuItemLink>
|
||||
<Divider
|
||||
variant='solid'
|
||||
my={theme.spacing.xs / 2}
|
||||
sx={theme => ({
|
||||
width: '110%',
|
||||
borderTopColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[2],
|
||||
margin: `${theme.spacing.xs / 2}px -4px`,
|
||||
})}
|
||||
/>
|
||||
<MenuItem icon={<Pencil1Icon />}>
|
||||
<Select
|
||||
size='xs'
|
||||
data={Object.keys(themes).map(t => ({ value: t, label: friendlyThemeName[t] }))}
|
||||
value={systemTheme}
|
||||
onChange={handleUpdateTheme}
|
||||
/>
|
||||
</MenuItem>
|
||||
</Group>
|
||||
</Popover>
|
||||
</Box>
|
||||
</div>
|
||||
</Header>
|
||||
}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Box>
|
||||
<Box component='main' sx={{ flexGrow: 1, p: 3, mt: 8 }}>
|
||||
{user && noPaper ? children : (
|
||||
<Paper elevation={0} sx={{ p: 2 }} variant='outlined'>
|
||||
{children}
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Paper withBorder padding='md' shadow='xs'>{children}</Paper>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import React, { forwardRef } from 'react';
|
|||
import clsx from 'clsx';
|
||||
import { useRouter } from 'next/router';
|
||||
import NextLink from 'next/link';
|
||||
import MuiLink from '@mui/material/Link';
|
||||
import { Text } from '@mantine/core';
|
||||
|
||||
export const NextLinkComposed = forwardRef(function NextLinkComposed(props: any, ref) {
|
||||
const { to, linkAs, href, replace, scroll, passHref, shallow, prefetch, locale, ...other } =
|
||||
|
@ -50,10 +50,10 @@ const Link = forwardRef(function Link(props: any, ref) {
|
|||
|
||||
if (isExternal) {
|
||||
if (noLinkStyle) {
|
||||
return <a className={className} href={href} ref={ref} {...other} />;
|
||||
return <Text variant='link' component='a' className={className} href={href} ref={ref} {...other} />;
|
||||
}
|
||||
|
||||
return <MuiLink className={className} href={href} ref={ref} {...other} />;
|
||||
return <Text component='a' variant='link' href={href} ref={ref} {...other} />;
|
||||
}
|
||||
|
||||
if (noLinkStyle) {
|
||||
|
@ -61,8 +61,9 @@ const Link = forwardRef(function Link(props: any, ref) {
|
|||
}
|
||||
|
||||
return (
|
||||
<MuiLink
|
||||
<Text
|
||||
component={NextLinkComposed}
|
||||
variant='link'
|
||||
linkAs={linkAs}
|
||||
className={className}
|
||||
ref={ref}
|
||||
|
|
|
@ -1,77 +1,96 @@
|
|||
import React from 'react';
|
||||
import { ThemeProvider } from '@emotion/react';
|
||||
import { CssBaseline } from '@mui/material';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
// themes
|
||||
import dark_blue from 'lib/themes/dark_blue';
|
||||
import light_blue from 'lib/themes/light_blue';
|
||||
import dark from 'lib/themes/dark';
|
||||
import ayu_dark from 'lib/themes/ayu_dark';
|
||||
import ayu_mirage from 'lib/themes/ayu_mirage';
|
||||
import ayu_light from 'lib/themes/ayu_light';
|
||||
import nord from 'lib/themes/nord';
|
||||
import polar from 'lib/themes/polar';
|
||||
import dracula from 'lib/themes/dracula';
|
||||
import matcha_dark_azul from 'lib/themes/matcha_dark_azul';
|
||||
import qogir_dark from 'lib/themes/qogir_dark';
|
||||
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import createTheme from 'lib/themes';
|
||||
import { MantineProvider, MantineThemeOverride } from '@mantine/core';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import { NotificationsProvider } from '@mantine/notifications';
|
||||
import { useColorScheme } from '@mantine/hooks';
|
||||
|
||||
export const themes = {
|
||||
'dark_blue': dark_blue,
|
||||
'dark': dark,
|
||||
'ayu_dark': ayu_dark,
|
||||
'ayu_mirage': ayu_mirage,
|
||||
'ayu_light': ayu_light,
|
||||
'nord': nord,
|
||||
'polar': polar,
|
||||
'dracula': dracula,
|
||||
'matcha_dark_azul': matcha_dark_azul,
|
||||
'qogir_dark': qogir_dark,
|
||||
system: (colorScheme: 'dark' | 'light') => colorScheme === 'dark' ? dark_blue : light_blue,
|
||||
dark_blue,
|
||||
light_blue,
|
||||
dark,
|
||||
ayu_dark,
|
||||
ayu_mirage,
|
||||
ayu_light,
|
||||
nord,
|
||||
dracula,
|
||||
matcha_dark_azul,
|
||||
qogir_dark,
|
||||
};
|
||||
|
||||
export const friendlyThemeName = {
|
||||
'system': 'System Theme',
|
||||
'dark_blue': 'Dark Blue',
|
||||
'light_blue': 'Light Blue',
|
||||
'dark': 'Very Dark',
|
||||
'ayu_dark': 'Ayu Dark',
|
||||
'ayu_mirage': 'Ayu Mirage',
|
||||
'ayu_light': 'Ayu Light',
|
||||
'nord': 'Nord',
|
||||
'polar': 'Polar',
|
||||
'dracula': 'Dracula',
|
||||
'matcha_dark_azul': 'Matcha Dark Azul',
|
||||
'qogir_dark': 'Qogir Dark',
|
||||
};
|
||||
|
||||
export default function ZiplineTheming({ Component, pageProps }) {
|
||||
let t;
|
||||
|
||||
export default function ZiplineTheming({ Component, pageProps, ...props }) {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
if (!user) t = themes.dark_blue;
|
||||
else {
|
||||
if (user.customTheme) {
|
||||
t = createTheme({
|
||||
type: 'dark',
|
||||
primary: user.customTheme.primary,
|
||||
secondary: user.customTheme.secondary,
|
||||
error: user.customTheme.error,
|
||||
warning: user.customTheme.warning,
|
||||
info: user.customTheme.info,
|
||||
border: user.customTheme.border,
|
||||
background: {
|
||||
main: user.customTheme.mainBackground,
|
||||
paper: user.customTheme.paperBackground,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
t = themes[user.systemTheme] ?? themes.dark_blue;
|
||||
}
|
||||
}
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
let theme: MantineThemeOverride;
|
||||
|
||||
if (!user) theme = themes.system(colorScheme);
|
||||
else if (user.systemTheme === 'system') theme = themes.system(colorScheme);
|
||||
else theme = themes[user.systemTheme] ?? themes.system(colorScheme);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty('color-scheme', theme.colorScheme);
|
||||
}, [user, theme]);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={t}>
|
||||
<CssBaseline />
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
theme={theme}
|
||||
styles={{
|
||||
AppShell: t => ({
|
||||
root: {
|
||||
backgroundColor: t.other.AppShell_backgroundColor,
|
||||
},
|
||||
}),
|
||||
Popover: {
|
||||
inner: {
|
||||
width: 200,
|
||||
},
|
||||
},
|
||||
Accordion: {
|
||||
itemTitle: {
|
||||
border: 0,
|
||||
},
|
||||
itemOpened: {
|
||||
border: 0,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ModalsProvider>
|
||||
<NotificationsProvider>
|
||||
{props.children ? props.children : <Component {...pageProps} />}
|
||||
</NotificationsProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
import React from 'react';
|
||||
import { styled, Select as MuiSelect, Input } from '@mui/material';
|
||||
import { makeStyles } from '@mui/styles';
|
||||
import { useTheme } from '@mui/system';
|
||||
|
||||
const CssInput = styled(Input)(({ theme }) => ({
|
||||
'& label.Mui-focused': {
|
||||
color: 'white',
|
||||
},
|
||||
'&': {
|
||||
color: 'white',
|
||||
},
|
||||
'&:before': {
|
||||
borderBottomColor: '#fff8',
|
||||
},
|
||||
'&&:hover:before': {
|
||||
borderBottomColor: theme.palette.primary.dark,
|
||||
},
|
||||
'&:after': {
|
||||
borderBottomColor: theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
|
||||
export default function Select({ ...other }) {
|
||||
return (
|
||||
<MuiSelect input={<CssInput />} {...other}/>
|
||||
);
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
import React from 'react';
|
||||
import { styled, TextField, Box } from '@mui/material';
|
||||
|
||||
const CssTextField = styled(TextField)(({ theme }) => ({
|
||||
'& label.Mui-focused': {
|
||||
color: 'white',
|
||||
},
|
||||
'& input': {
|
||||
color: 'white',
|
||||
},
|
||||
'& .MuiInput-underline:before': {
|
||||
borderBottomColor: '#fff8',
|
||||
},
|
||||
'&& .MuiInput-underline:hover:before': {
|
||||
borderBottomColor: theme.palette.primary.dark,
|
||||
},
|
||||
'& .MuiInput-underline:after': {
|
||||
borderBottomColor: theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
export default function TextInput({ id, label, formik, ...other }) {
|
||||
return (
|
||||
<Box>
|
||||
<CssTextField
|
||||
id={id}
|
||||
name={id}
|
||||
label={label}
|
||||
value={formik.values[id]}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched[id] && Boolean(formik.errors[id])}
|
||||
helperText={formik.touched[id] && formik.errors[id]}
|
||||
// @ts-ignore
|
||||
variant='standard'
|
||||
sx={{ pb: 0.5 }}
|
||||
{...other}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
|
@ -1,32 +1,15 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TablePagination,
|
||||
TableRow,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Typography,
|
||||
Grid,
|
||||
Skeleton,
|
||||
CardActionArea,
|
||||
CardMedia,
|
||||
Card as MuiCard,
|
||||
} from '@mui/material';
|
||||
import AudioIcon from '@mui/icons-material/Audiotrack';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import CopyIcon from '@mui/icons-material/FileCopy';
|
||||
import OpenIcon from '@mui/icons-material/OpenInNew';
|
||||
|
||||
import Link from 'components/Link';
|
||||
import Card from 'components/Card';
|
||||
import Alert from 'components/Alert';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import Image from 'components/Image';
|
||||
import ImagesTable from 'components/ImagesTable';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import { Box, Text, Table, Skeleton, Title, SimpleGrid } from '@mantine/core';
|
||||
import { randomId, useClipboard } from '@mantine/hooks';
|
||||
import Link from 'components/Link';
|
||||
import { CopyIcon, Cross1Icon, TrashIcon } from '@modulz/radix-icons';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
|
||||
type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify';
|
||||
|
||||
|
@ -44,50 +27,34 @@ export function bytesToRead(bytes: number) {
|
|||
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ id: 'file', label: 'Name', minWidth: 170, align: 'inherit' as Aligns },
|
||||
{ id: 'mimetype', label: 'Type', minWidth: 100, align: 'inherit' as Aligns },
|
||||
{
|
||||
id: 'created_at',
|
||||
label: 'Date',
|
||||
minWidth: 170,
|
||||
align: 'right' as Aligns,
|
||||
format: (value) => new Date(value).toLocaleString(),
|
||||
},
|
||||
];
|
||||
|
||||
function StatText({ children }) {
|
||||
return <Typography variant='h5' color='GrayText'>{children}</Typography>;
|
||||
return <Text color='gray' size='xl'>{children}</Text>;
|
||||
}
|
||||
|
||||
function StatTable({ rows, columns }) {
|
||||
return (
|
||||
<TableContainer sx={{ pt: 1 }}>
|
||||
<Table sx={{ minWidth: 100 }} size='small'>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<Table highlightOnHover>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map(col => (
|
||||
<TableCell key={col.name} sx={{ borderColor: t => t.palette.divider }}>{col.name}</TableCell>
|
||||
<th key={randomId()}>{col.name}</th>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<TableRow
|
||||
hover
|
||||
key={row.username}
|
||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||
>
|
||||
<tr key={randomId()}>
|
||||
{columns.map(col => (
|
||||
<TableCell key={col.id} sx={{ borderColor: t => t.palette.divider }}>
|
||||
<td key={randomId()}>
|
||||
{col.format ? col.format(row[col.id]) : row[col.id]}
|
||||
</TableCell>
|
||||
</td>
|
||||
))}
|
||||
</TableRow>
|
||||
</tr>
|
||||
))}
|
||||
</TableBody>
|
||||
</tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -96,35 +63,51 @@ export default function Dashboard() {
|
|||
|
||||
const [images, setImages] = useState([]);
|
||||
const [recent, setRecent] = useState([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [severity, setSeverity] = useState('success');
|
||||
const [message, setMessage] = useState('Saved');
|
||||
const clipboard = useClipboard();
|
||||
const notif = useNotifications();
|
||||
|
||||
const updateImages = async () => {
|
||||
const imgs = await useFetch('/api/user/files');
|
||||
const recent = await useFetch('/api/user/recent?filter=media');
|
||||
const stts = await useFetch('/api/stats');
|
||||
setImages(imgs);
|
||||
setImages(imgs.map(x => ({ ...x, created_at: new Date(x.created_at).toLocaleString() })));
|
||||
setStats(stts);
|
||||
setRecent(recent);
|
||||
};
|
||||
|
||||
const handleChangePage = (event, newPage) => {
|
||||
setPage(newPage);
|
||||
const deleteImage = async ({ original }) => {
|
||||
const res = await useFetch('/api/user/files', 'DELETE', { id: original.id });
|
||||
if (!res.error) {
|
||||
updateImages();
|
||||
notif.showNotification({
|
||||
title: 'Image Deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <TrashIcon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'Failed to delete image',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = event => {
|
||||
setRowsPerPage(+event.target.value);
|
||||
setPage(0);
|
||||
const copyImage = async ({ original }) => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${original.url}`);
|
||||
notif.showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async image => {
|
||||
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
|
||||
if (!res.error) updateImages();
|
||||
const viewImage = async ({ original }) => {
|
||||
window.open(`${window.location.protocol}//${window.location.host}${original.url}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -133,117 +116,75 @@ export default function Dashboard() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
|
||||
<Title>Welcome back {user?.username}</Title>
|
||||
<Text color='gray' sx={{ paddingBottom: 4 }}>You have <b>{images.length ? images.length : '...'}</b> files</Text>
|
||||
|
||||
<Typography variant='h4'>Welcome back {user?.username}</Typography>
|
||||
<Typography color='GrayText' pb={2}>You have <b>{images.length ? images.length : '...'}</b> files</Typography>
|
||||
|
||||
<Typography variant='h4'>Recent Files</Typography>
|
||||
<Grid container spacing={4} py={2}>
|
||||
{recent.length ? recent.map(image => (
|
||||
<Grid item xs={12} sm={3} key={image.id}>
|
||||
<MuiCard sx={{ minWidth: '100%' }}>
|
||||
<CardActionArea>
|
||||
<CardMedia
|
||||
sx={{ height: 220 }}
|
||||
image={image.url}
|
||||
title={image.file}
|
||||
controls
|
||||
component={image.mimetype.split('/')[0] === 'audio' ? AudioIcon : image.mimetype.split('/')[0]} // this is done because audio without controls is hidden
|
||||
/>
|
||||
</CardActionArea>
|
||||
</MuiCard>
|
||||
</Grid>
|
||||
)) : [1,2,3,4].map(x => (
|
||||
<Grid item xs={12} sm={3} key={x}>
|
||||
<Skeleton variant='rectangular' width='100%' height={220} sx={{ borderRadius: 1 }}/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
<Typography variant='h4'>Stats</Typography>
|
||||
<Grid container spacing={4} py={2}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card name='Size' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.size : <Skeleton variant='text' />}</StatText>
|
||||
<Typography variant='h3'>Average Size</Typography>
|
||||
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton variant='text' />}</StatText>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card name='Images' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.count : <Skeleton variant='text' />}</StatText>
|
||||
<Typography variant='h3'>Views</Typography>
|
||||
<StatText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton variant='text' />}</StatText>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card name='Users' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.count_users : <Skeleton variant='text' />}</StatText>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Card name='Images' sx={{ my: 2 }} elevation={0} variant='outlined'>
|
||||
<Link href='/dashboard/files' pb={2}>View Files</Link>
|
||||
<TableContainer sx={{ maxHeight: 440 }}>
|
||||
<Table size='small'>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columns.map(column => (
|
||||
<TableCell
|
||||
key={column.id}
|
||||
align={column.align}
|
||||
sx={{ minWidth: column.minWidth, borderColor: t => t.palette.divider }}
|
||||
<Title>Recent Files</Title>
|
||||
<SimpleGrid
|
||||
cols={4}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{column.label}
|
||||
</TableCell>
|
||||
{recent.length ? recent.map(image => (
|
||||
// eslint-disable-next-line jsx-a11y/alt-text
|
||||
<Image key={randomId()} image={image} updateImages={updateImages} />
|
||||
)) : [1,2,3,4].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
|
||||
</div>
|
||||
))}
|
||||
<TableCell sx={{ minWidth: 200, borderColor: t => t.palette.divider }} align='right'>
|
||||
Actions
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{images
|
||||
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
|
||||
.map(row => {
|
||||
return (
|
||||
<TableRow hover role='checkbox' tabIndex={-1} key={row.id}>
|
||||
{columns.map(column => {
|
||||
const value = row[column.id];
|
||||
return (
|
||||
<TableCell key={column.id} align={column.align} sx={{ borderColor: t => t.palette.divider }}>
|
||||
{column.format ? column.format(value) : value}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
<TableCell align='right' sx={{ borderColor: t => t.palette.divider }}>
|
||||
<ButtonGroup variant='text'>
|
||||
<Button onClick={() => handleDelete(row)} color='error' size='small'><DeleteIcon /></Button>
|
||||
<Button onClick={() => window.open(row.url, '_blank')} color='warning' size='small'><OpenIcon /></Button>
|
||||
<Button onClick={() => {
|
||||
copy(window.location.origin + row.url);
|
||||
setOpen(true);
|
||||
setSeverity('success');
|
||||
setMessage('Copied to clipboard');
|
||||
}} color='info' size='small'><CopyIcon /></Button>
|
||||
</ButtonGroup>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
rowsPerPageOptions={[10, 25, 100]}
|
||||
component='div'
|
||||
count={images.length}
|
||||
rowsPerPage={rowsPerPage}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Title mt='md'>Stats</Title>
|
||||
<Text>View more stats here <Link href='/dashboard/stats'>here</Link>.</Text>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
<Card name='Size' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.size : <Skeleton height={8} />}</StatText>
|
||||
<Title order={2}>Average Size</Title>
|
||||
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
<Card name='Files per User' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'>
|
||||
<Card name='Images' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.count : <Skeleton height={8} />}</StatText>
|
||||
<Title order={2}>Views</Title>
|
||||
<StatText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
<Card name='Users' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.count_users : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<ImagesTable
|
||||
columns={[
|
||||
{ accessor: 'file', Header: 'Name', minWidth: 170, align: 'inherit' as Aligns },
|
||||
{ accessor: 'mimetype', Header: 'Type', minWidth: 100, align: 'inherit' as Aligns },
|
||||
{ accessor: 'created_at', Header: 'Date' },
|
||||
]}
|
||||
data={images}
|
||||
deleteImage={deleteImage}
|
||||
copyImage={copyImage}
|
||||
viewImage={viewImage}
|
||||
/>
|
||||
|
||||
{/* <Title mt='md'>Files</Title>
|
||||
<Text>View previews of your files in the <Link href='/dashboard/files'>browser</Link>.</Text>
|
||||
<ReactTable
|
||||
columns={[
|
||||
{ accessor: 'file', Header: 'Name', minWidth: 170, align: 'inherit' as Aligns },
|
||||
{ accessor: 'mimetype', Header: 'Type', minWidth: 100, align: 'inherit' as Aligns },
|
||||
{ accessor: 'created_at', Header: 'Date' },
|
||||
]}
|
||||
data={images}
|
||||
pagination
|
||||
/>
|
||||
<Card name='Files per User' mt={22}>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'username', name: 'Name' },
|
||||
|
@ -251,14 +192,14 @@ export default function Dashboard() {
|
|||
]}
|
||||
rows={stats ? stats.count_by_user : []} />
|
||||
</Card>
|
||||
<Card name='Types' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'>
|
||||
<Card name='Types' mt={22}>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'mimetype', name: 'Type' },
|
||||
{ id: 'count', name: 'Count' },
|
||||
]}
|
||||
rows={stats ? stats.types_count : []} />
|
||||
</Card>
|
||||
</Card> */}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,27 +1,24 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Grid, Pagination, Box, Typography, Accordion, AccordionSummary, AccordionDetails } from '@mui/material';
|
||||
import { ExpandMore } from '@mui/icons-material';
|
||||
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import ZiplineImage from 'components/Image';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { Box, Accordion, Pagination, Title, SimpleGrid, Skeleton, Group, ActionIcon } from '@mantine/core';
|
||||
import { PlusIcon } from '@modulz/radix-icons';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Files() {
|
||||
const [pages, setPages] = useState([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [favoritePages, setFavoritePages] = useState([]);
|
||||
const [favoritePage, setFavoritePage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const updatePages = async favorite => {
|
||||
setLoading(true);
|
||||
const pages = await useFetch('/api/user/files?paged=true&filter=media');
|
||||
if (favorite) {
|
||||
const fPages = await useFetch('/api/user/files?paged=true&favorite=media');
|
||||
setFavoritePages(fPages);
|
||||
}
|
||||
setPages(pages);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -30,59 +27,76 @@ export default function Files() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={loading}/>
|
||||
{!pages.length ? (
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
pt={2}
|
||||
pb={3}
|
||||
<Group>
|
||||
<Title sx={{ marginBottom: 12 }}>Files</Title>
|
||||
<Link href='/dashboard/upload' passHref>
|
||||
<ActionIcon component='a' variant='filled' color='primary'><PlusIcon/></ActionIcon>
|
||||
</Link>
|
||||
</Group>
|
||||
<Accordion
|
||||
offsetIcon={false}
|
||||
sx={t => ({
|
||||
marginTop: 2,
|
||||
border: '1px solid',
|
||||
marginBottom: 12,
|
||||
borderColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0] ,
|
||||
})}
|
||||
>
|
||||
<Accordion.Item label={<Title>Favorite Files</Title>}>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
<Typography variant='h4'>No Files</Typography>
|
||||
</Box>
|
||||
) : <Typography variant='h4'>Files</Typography>}
|
||||
{favoritePages.length ? (
|
||||
<Accordion sx={{ my: 2, border: 1, borderColor: t => t.palette.divider }} elevation={0}>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Typography variant='h4'>Favorite Files</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={2}>
|
||||
{favoritePages.length ? favoritePages[(favoritePage - 1) ?? 0].map(image => (
|
||||
<Grid item xs={12} sm={3} key={image.id}>
|
||||
<div key={image.id}>
|
||||
<ZiplineImage image={image} updateImages={() => updatePages(true)} />
|
||||
</Grid>
|
||||
</div>
|
||||
)) : null}
|
||||
</Grid>
|
||||
{favoritePages.length ? (
|
||||
</SimpleGrid>
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
pt={2}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Pagination count={favoritePages.length} page={favoritePage} onChange={(_, v) => setFavoritePage(v)}/>
|
||||
<Pagination total={favoritePages.length} page={favoritePage} onChange={setFavoritePage}/>
|
||||
</Box>
|
||||
) : null}
|
||||
</AccordionDetails>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
) : null}
|
||||
<Grid container spacing={2}>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{pages.length ? pages[(page - 1) ?? 0].map(image => (
|
||||
<Grid item xs={12} sm={3} key={image.id}>
|
||||
<ZiplineImage image={image} updateImages={updatePages} />
|
||||
</Grid>
|
||||
)) : null}
|
||||
</Grid>
|
||||
<div key={image.id}>
|
||||
<ZiplineImage image={image} updateImages={() => updatePages(true)} />
|
||||
</div>
|
||||
)) : [1,2,3,4].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{pages.length ? (
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
pt={2}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Pagination count={pages.length} page={page} onChange={(_, v) => setPage(v)}/>
|
||||
<Pagination total={pages.length} page={page} onChange={setPage}/>
|
||||
</Box>
|
||||
) : null}
|
||||
</>
|
||||
|
|
|
@ -1,72 +1,21 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Button, Box, Typography, MenuItem, Tooltip } from '@mui/material';
|
||||
import Download from '@mui/icons-material/Download';
|
||||
import React from 'react';
|
||||
|
||||
import { useFormik } from 'formik';
|
||||
import * as yup from 'yup';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import Alert from 'components/Alert';
|
||||
import TextInput from 'components/input/TextInput';
|
||||
import Select from 'components/input/Select';
|
||||
import Link from 'components/Link';
|
||||
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
const validationSchema = yup.object({
|
||||
username: yup
|
||||
.string()
|
||||
.required('Username is required'),
|
||||
});
|
||||
|
||||
const themeValidationSchema = yup.object({
|
||||
type: yup
|
||||
.string()
|
||||
.required('Type (dark, light) is required is required'),
|
||||
primary: yup
|
||||
.string()
|
||||
.required('Primary color is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
secondary: yup
|
||||
.string()
|
||||
.required('Secondary color is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
error: yup
|
||||
.string()
|
||||
.required('Error color is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
warning: yup
|
||||
.string()
|
||||
.required('Warning color is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
info: yup
|
||||
.string()
|
||||
.required('Info color is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
border: yup
|
||||
.string()
|
||||
.required('Border color is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
mainBackground: yup
|
||||
.string()
|
||||
.required('Main Background is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
paperBackground: yup
|
||||
.string()
|
||||
.required('Paper Background is required')
|
||||
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
|
||||
|
||||
});
|
||||
import { useForm } from '@mantine/hooks';
|
||||
import { Tooltip, TextInput, Button, Text, Title, Group, ColorInput } from '@mantine/core';
|
||||
import { DownloadIcon } from '@modulz/radix-icons';
|
||||
|
||||
function VarsTooltip({ children }) {
|
||||
return (
|
||||
<Tooltip title={
|
||||
<Tooltip position='top' placement='center' color='' label={
|
||||
<>
|
||||
<Typography><b>{'{image.file}'}</b> - file name</Typography>
|
||||
<Typography><b>{'{image.mimetype}'}</b> - mimetype</Typography>
|
||||
<Typography><b>{'{image.id}'}</b> - id of the image</Typography>
|
||||
<Typography><b>{'{user.name}'}</b> - your username</Typography>
|
||||
<Text><b>{'{image.file}'}</b> - file name</Text>
|
||||
<Text><b>{'{image.mimetype}'}</b> - mimetype</Text>
|
||||
<Text><b>{'{image.id}'}</b> - id of the image</Text>
|
||||
<Text><b>{'{user.name}'}</b> - your username</Text>
|
||||
visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables
|
||||
</>
|
||||
}>
|
||||
|
@ -78,12 +27,6 @@ function VarsTooltip({ children }) {
|
|||
export default function Manage() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
const dispatch = useStoreDispatch();
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [severity, setSeverity] = useState('success');
|
||||
const [message, setMessage] = useState('Saved');
|
||||
|
||||
const genShareX = (withEmbed: boolean = false, withZws: boolean = false) => {
|
||||
const config = {
|
||||
|
@ -111,7 +54,7 @@ export default function Manage() {
|
|||
pseudoElement.parentNode.removeChild(pseudoElement);
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: user.username,
|
||||
password: '',
|
||||
|
@ -119,17 +62,16 @@ export default function Manage() {
|
|||
embedColor: user.embedColor,
|
||||
embedSiteName: user.embedSiteName ?? '',
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: async values => {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
if (cleanUsername === '') return formik.setFieldError('username', 'Username can\'t be nothing');
|
||||
|
||||
setLoading(true);
|
||||
if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
|
||||
|
||||
const data = {
|
||||
username: cleanUsername,
|
||||
|
@ -142,119 +84,36 @@ export default function Manage() {
|
|||
const newUser = await useFetch('/api/user', 'PATCH', data);
|
||||
|
||||
if (newUser.error) {
|
||||
setLoading(false);
|
||||
setMessage('An error occured');
|
||||
setSeverity('error');
|
||||
setOpen(true);
|
||||
} else {
|
||||
dispatch(updateUser(newUser));
|
||||
setLoading(false);
|
||||
setMessage('Saved user');
|
||||
setSeverity('success');
|
||||
setOpen(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const customThemeFormik = useFormik({
|
||||
initialValues: {
|
||||
type: user.customTheme?.type || 'dark',
|
||||
primary: user.customTheme?.primary || '',
|
||||
secondary: user.customTheme?.secondary || '',
|
||||
error: user.customTheme?.error || '',
|
||||
warning: user.customTheme?.warning || '',
|
||||
info: user.customTheme?.info || '',
|
||||
border: user.customTheme?.border || '',
|
||||
mainBackground: user.customTheme?.mainBackground || '',
|
||||
paperBackground: user.customTheme?.paperBackground || '',
|
||||
},
|
||||
validationSchema: themeValidationSchema,
|
||||
onSubmit: async values => {
|
||||
setLoading(true);
|
||||
const newUser = await useFetch('/api/user', 'PATCH', { customTheme: values });
|
||||
|
||||
if (newUser.error) {
|
||||
setLoading(false);
|
||||
setMessage('An error occured');
|
||||
setSeverity('error');
|
||||
setOpen(true);
|
||||
} else {
|
||||
dispatch(updateUser(newUser));
|
||||
router.replace(router.pathname);
|
||||
setLoading(false);
|
||||
setMessage('Saved theme');
|
||||
setSeverity('success');
|
||||
setOpen(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={loading}/>
|
||||
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
|
||||
|
||||
<Typography variant='h4'>Manage User</Typography>
|
||||
<Title>Manage User</Title>
|
||||
<VarsTooltip>
|
||||
<Typography variant='caption' color='GrayText'>Want to use variables in embed text? Hover on this or visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables</Typography>
|
||||
<Text color='gray'>Want to use variables in embed text? Hover on this or visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables</Text>
|
||||
</VarsTooltip>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput fullWidth id='username' label='Username' formik={formik} />
|
||||
<TextInput fullWidth id='password' label='Password' formik={formik} type='password' />
|
||||
<TextInput fullWidth id='embedTitle' label='Embed Title' formik={formik} />
|
||||
<TextInput fullWidth id='embedColor' label='Embed Color' formik={formik} />
|
||||
<TextInput fullWidth id='embedSiteName' label='Embed Site Name' formik={formik} />
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='right'
|
||||
alignItems='right'
|
||||
pt={2}
|
||||
>
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<TextInput id='username' label='Username' {...form.getInputProps('username')} />
|
||||
<TextInput id='password' label='Password'type='password' {...form.getInputProps('password')} />
|
||||
<TextInput id='embedTitle' label='Embed Title' {...form.getInputProps('embedTitle')} />
|
||||
<ColorInput id='embedColor' label='Embed Color' {...form.getInputProps('embedColor')} />
|
||||
<TextInput id='embedSiteName' label='Embed Site Name' {...form.getInputProps('embedSiteName')} />
|
||||
<Group position='right' sx={{ paddingTop: 12 }}>
|
||||
<Button
|
||||
variant='contained'
|
||||
type='submit'
|
||||
>Save User</Button>
|
||||
</Box>
|
||||
</Group>
|
||||
</form>
|
||||
<Typography variant='h4' py={2}>Manage Theme</Typography>
|
||||
<form onSubmit={customThemeFormik.handleSubmit}>
|
||||
<Select
|
||||
id='type'
|
||||
name='type'
|
||||
label='Type'
|
||||
value={customThemeFormik.values['type']}
|
||||
onChange={customThemeFormik.handleChange}
|
||||
error={customThemeFormik.touched['type'] && Boolean(customThemeFormik.errors['type'])}
|
||||
variant='standard'
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value='dark'>Dark Theme</MenuItem>
|
||||
<MenuItem value='light'>Light Theme</MenuItem>
|
||||
</Select>
|
||||
<TextInput fullWidth id='primary' label='Primary Color' formik={customThemeFormik} />
|
||||
<TextInput fullWidth id='secondary' label='Secondary Color' formik={customThemeFormik} />
|
||||
<TextInput fullWidth id='error' label='Error Color' formik={customThemeFormik} />
|
||||
<TextInput fullWidth id='warning' label='Warning Color' formik={customThemeFormik} />
|
||||
<TextInput fullWidth id='info' label='Info Color' formik={customThemeFormik} />
|
||||
<TextInput fullWidth id='border' label='Border Color' formik={customThemeFormik} />
|
||||
<TextInput fullWidth id='mainBackground' label='Main Background' formik={customThemeFormik} />
|
||||
<TextInput fullWidth id='paperBackground' label='Paper Background' formik={customThemeFormik} />
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='right'
|
||||
alignItems='right'
|
||||
pt={2}
|
||||
>
|
||||
<Button
|
||||
variant='contained'
|
||||
type='submit'
|
||||
>Save Theme</Button>
|
||||
</Box>
|
||||
</form>
|
||||
<Typography variant='h4' py={2}>ShareX Config</Typography>
|
||||
<Button variant='contained' onClick={() => genShareX(false)} startIcon={<Download />}>ShareX Config</Button>
|
||||
<Button variant='contained' sx={{ marginLeft: 1 }} onClick={() => genShareX(true)} startIcon={<Download />}>ShareX Config with Embed</Button>
|
||||
<Button variant='contained' sx={{ marginLeft: 1 }} onClick={() => genShareX(false, true)} startIcon={<Download />}>ShareX Config with ZWS</Button>
|
||||
|
||||
<Title sx={{ paddingTop: 12, paddingBottom: 12 }}>ShareX Config</Title>
|
||||
<Group>
|
||||
<Button onClick={() => genShareX(false)} rightIcon={<DownloadIcon />}>ShareX Config</Button>
|
||||
<Button onClick={() => genShareX(true)} rightIcon={<DownloadIcon />}>ShareX Config with Embed</Button>
|
||||
<Button onClick={() => genShareX(false, true)} rightIcon={<DownloadIcon />}>ShareX Config with ZWS</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
118
src/components/pages/Stats.tsx
Normal file
118
src/components/pages/Stats.tsx
Normal file
|
@ -0,0 +1,118 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import Card from 'components/Card';
|
||||
import Image from 'components/Image';
|
||||
import ImagesTable from 'components/ImagesTable';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import { Box, Text, Table, Skeleton, Title, SimpleGrid } from '@mantine/core';
|
||||
import { randomId, useClipboard } from '@mantine/hooks';
|
||||
import Link from 'components/Link';
|
||||
import { CopyIcon, Cross1Icon, TrashIcon } from '@modulz/radix-icons';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
|
||||
type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify';
|
||||
|
||||
export function bytesToRead(bytes: number) {
|
||||
if (isNaN(bytes)) return '0.0 B';
|
||||
if (bytes === Infinity) return '0.0 B';
|
||||
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let num = 0;
|
||||
|
||||
while (bytes > 1024) {
|
||||
bytes /= 1024;
|
||||
++num;
|
||||
}
|
||||
|
||||
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||
}
|
||||
|
||||
function StatText({ children }) {
|
||||
return <Text color='gray' size='xl'>{children}</Text>;
|
||||
}
|
||||
|
||||
function StatTable({ rows, columns }) {
|
||||
return (
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<Table highlightOnHover>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map(col => (
|
||||
<th key={randomId()}>{col.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<tr key={randomId()}>
|
||||
{columns.map(col => (
|
||||
<td key={randomId()}>
|
||||
{col.format ? col.format(row[col.id]) : row[col.id]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
|
||||
const [stats, setStats] = useState(null);
|
||||
|
||||
const update = async () => {
|
||||
const stts = await useFetch('/api/stats');
|
||||
setStats(stts);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
update();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Stats</Title>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
<Card name='Size' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.size : <Skeleton height={8} />}</StatText>
|
||||
<Title order={2}>Average Size</Title>
|
||||
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
<Card name='Images' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.count : <Skeleton height={8} />}</StatText>
|
||||
<Title order={2}>Views</Title>
|
||||
<StatText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
<Card name='Users' sx={{ height: '100%' }}>
|
||||
<StatText>{stats ? stats.count_users : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Card name='Files per User' mt={22}>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'username', name: 'Name' },
|
||||
{ id: 'count', name: 'Files' },
|
||||
]}
|
||||
rows={stats ? stats.count_by_user : []} />
|
||||
</Card>
|
||||
<Card name='Types' mt={22}>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'mimetype', name: 'Type' },
|
||||
{ id: 'count', name: 'Count' },
|
||||
]}
|
||||
rows={stats ? stats.types_count : []} />
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,39 +1,65 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Typography, Button, CardActionArea, Paper, Box } from '@mui/material';
|
||||
import { Upload as UploadIcon } from '@mui/icons-material';
|
||||
import Dropzone from 'react-dropzone';
|
||||
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import Alert from 'components/Alert';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import CenteredBox from 'components/CenteredBox';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import Link from 'components/Link';
|
||||
import { Button, Group, Text, useMantineTheme } from '@mantine/core';
|
||||
import { ImageIcon, UploadIcon, CrossCircledIcon } from '@modulz/radix-icons';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
|
||||
function ImageUploadIcon({ status, ...props }) {
|
||||
if (status.accepted) {
|
||||
return <UploadIcon {...props} />;
|
||||
}
|
||||
|
||||
if (status.rejected) {
|
||||
return <CrossCircledIcon {...props} />;
|
||||
}
|
||||
|
||||
return <ImageIcon {...props} />;
|
||||
}
|
||||
|
||||
function getIconColor(status, theme) {
|
||||
return status.accepted
|
||||
? theme.colors[theme.primaryColor][6]
|
||||
: status.rejected
|
||||
? theme.colors.red[6]
|
||||
: theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[0]
|
||||
: theme.black;
|
||||
}
|
||||
|
||||
export default function Upload({ route }) {
|
||||
const theme = useMantineTheme();
|
||||
const notif = useNotifications();
|
||||
const clipboard = useClipboard();
|
||||
const user = useStoreSelector(state => state.user);
|
||||
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [severity, setSeverity] = useState('success');
|
||||
const [message, setMessage] = useState('Saved');
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('paste', (e: ClipboardEvent) => {
|
||||
const item = Array.from(e.clipboardData.items).find(x => /^image/.test(x.type));
|
||||
const blob = item.getAsFile();
|
||||
setFiles([...files, new File([blob], blob.name, { type: blob.type })]);
|
||||
notif.showNotification({
|
||||
title: 'Image Imported',
|
||||
message: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const handleUpload = async () => {
|
||||
const body = new FormData();
|
||||
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) body.append('file', files[i]);
|
||||
|
||||
setLoading(true);
|
||||
const id = notif.showNotification({
|
||||
title: 'Uploading Images...',
|
||||
message: '',
|
||||
loading: true,
|
||||
});
|
||||
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
@ -43,63 +69,52 @@ export default function Upload({ route }) {
|
|||
});
|
||||
const json = await res.json();
|
||||
if (res.ok && json.error === undefined) {
|
||||
setOpen(true);
|
||||
setSeverity('success');
|
||||
|
||||
//@ts-ignore
|
||||
setMessage(<>Copied first image to clipboard! <br/>{json.files.map(x => (<Link key={x} href={x}>{x}<br/></Link>))}</>);
|
||||
copy(json.url);
|
||||
notif.updateNotification(id, {
|
||||
title: 'Upload Successful',
|
||||
message: <>Copied first image to clipboard! <br/>{json.files.map(x => (<Link key={x} href={x}>{x}<br/></Link>))}</>,
|
||||
color: 'green',
|
||||
icon: <UploadIcon />,
|
||||
});
|
||||
clipboard.copy(json.url);
|
||||
setFiles([]);
|
||||
} else {
|
||||
setOpen(true);
|
||||
setSeverity('error');
|
||||
setMessage('Could not upload file: ' + json.error);
|
||||
notif.updateNotification(id, {
|
||||
title: 'Upload Failed',
|
||||
message: json.error,
|
||||
color: 'red',
|
||||
icon: <CrossCircledIcon />,
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={loading}/>
|
||||
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
|
||||
|
||||
<Typography variant='h4' pb={2}>Upload file</Typography>
|
||||
<Dropzone onDrop={acceptedFiles => setFiles([...files, ...acceptedFiles])}>
|
||||
{({getRootProps, getInputProps}) => (
|
||||
<CardActionArea>
|
||||
<Paper
|
||||
elevation={0}
|
||||
variant='outlined'
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
display: 'block',
|
||||
p: 5,
|
||||
}}
|
||||
{...getRootProps()}
|
||||
<Dropzone
|
||||
onDrop={(f) => setFiles([...files, ...f])}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<CenteredBox><UploadIcon sx={{ fontSize: 100 }} /></CenteredBox>
|
||||
<CenteredBox><Typography variant='h5'>Drag an image or click to upload an image.</Typography></CenteredBox>
|
||||
{files.map(file => (
|
||||
<CenteredBox key={file.name}><Typography variant='h6'>{file.name}</Typography></CenteredBox>
|
||||
))}
|
||||
</Paper>
|
||||
</CardActionArea>
|
||||
{(status) => (
|
||||
<>
|
||||
<Group position='center' spacing='xl' style={{ minHeight: 220, pointerEvents: 'none' }}>
|
||||
<ImageUploadIcon
|
||||
status={status}
|
||||
style={{ width: 80, height: 80, color: getIconColor(status, theme) }}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Text size='xl' inline>
|
||||
Drag images here or click to select files
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group position='center' spacing='xl' style={{ pointerEvents: 'none' }}>
|
||||
{files.map(file => (<Text key={file.name} weight='bold'>{file.name}</Text>))}
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Dropzone>
|
||||
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='right'
|
||||
alignItems='right'
|
||||
pt={2}
|
||||
>
|
||||
<Button
|
||||
variant='contained'
|
||||
onClick={handleUpload}
|
||||
>Upload</Button>
|
||||
</Box>
|
||||
<Group position='right'>
|
||||
<Button leftIcon={<UploadIcon />} mt={12} onClick={handleUpload}>Upload</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,86 +1,68 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Grid, Card, CardHeader, Box, Typography, IconButton, Link, Dialog, DialogContent, DialogActions, Button, DialogTitle, TextField } from '@mui/material';
|
||||
import { ContentCopy as CopyIcon, DeleteForever as DeleteIcon, Add as AddIcon } from '@mui/icons-material';
|
||||
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import Alert from 'components/Alert';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { useFormik } from 'formik';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import * as yup from 'yup';
|
||||
|
||||
function TextInput({ id, label, formik, ...other }) {
|
||||
return (
|
||||
<TextField
|
||||
id={id}
|
||||
name={id}
|
||||
label={label}
|
||||
value={formik.values[id]}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched[id] && Boolean(formik.errors[id])}
|
||||
helperText={formik.touched[id] && formik.errors[id]}
|
||||
variant='standard'
|
||||
fullWidth
|
||||
sx={{ pb: 0.5 }}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
}
|
||||
import { useClipboard, useForm } from '@mantine/hooks';
|
||||
import { CopyIcon, Cross1Icon, Link1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { Modal, Title, Group, Button, Box, Card, TextInput, ActionIcon, SimpleGrid, Skeleton } from '@mantine/core';
|
||||
|
||||
export default function Urls() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
const notif = useNotifications();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [urls, setURLS] = useState([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [severity, setSeverity] = useState('success');
|
||||
const [message, setMessage] = useState('Deleted');
|
||||
|
||||
const updateURLs = async () => {
|
||||
setLoading(true);
|
||||
const urls = await useFetch('/api/user/urls');
|
||||
|
||||
setURLS(urls);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const deleteURL = async u => {
|
||||
const url = await useFetch('/api/user/urls', 'DELETE', { id: u.id });
|
||||
if (url.error) {
|
||||
setSeverity('error');
|
||||
setMessage('Error: ' + url.error);
|
||||
setOpen(true);
|
||||
notif.showNotification({
|
||||
title: 'Failed to delete URL',
|
||||
message: url.error,
|
||||
icon: <TrashIcon />,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
setSeverity('success');
|
||||
setMessage(`Deleted ${u.vanity ?? u.id}`);
|
||||
setOpen(true);
|
||||
notif.showNotification({
|
||||
title: 'Deleted URL',
|
||||
message: '',
|
||||
icon: <Cross1Icon />,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
|
||||
updateURLs();
|
||||
};
|
||||
|
||||
const copyURL = u => {
|
||||
copy(`${window.location.protocol}//${window.location.host}${u.url}`);
|
||||
setSeverity('success');
|
||||
setMessage(`Copied URL: ${window.location.protocol}//${window.location.host}${u.url}`);
|
||||
setOpen(true);
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
|
||||
notif.showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
url: '',
|
||||
vanity: '',
|
||||
},
|
||||
validationSchema: yup.object({
|
||||
});
|
||||
|
||||
}),
|
||||
onSubmit: async (values) => {
|
||||
const onSubmit = async (values) => {
|
||||
const cleanURL = values.url.trim();
|
||||
const cleanVanity = values.vanity.trim();
|
||||
|
||||
if (cleanURL === '') return formik.setFieldError('username', 'Username can\'t be nothing');
|
||||
if (cleanURL === '') return form.setFieldError('url', 'URL can\'t be nothing');
|
||||
|
||||
const data = {
|
||||
url: cleanURL,
|
||||
|
@ -88,7 +70,6 @@ export default function Urls() {
|
|||
};
|
||||
|
||||
setCreateOpen(false);
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/shorten', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
@ -100,20 +81,23 @@ export default function Urls() {
|
|||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
setSeverity('error');
|
||||
setMessage('Could\'nt create URL: ' + json.error);
|
||||
setOpen(true);
|
||||
} else {
|
||||
setSeverity('success');
|
||||
setMessage('Copied URL: ' + json.url);
|
||||
copy(json.url);
|
||||
setOpen(true);
|
||||
setCreateOpen(false);
|
||||
updateURLs();
|
||||
}
|
||||
setLoading(false);
|
||||
},
|
||||
notif.showNotification({
|
||||
title: 'Failed to create URL',
|
||||
message: json.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'URL shortened',
|
||||
message: json.url,
|
||||
color: 'green',
|
||||
icon: <Link1Icon />,
|
||||
});
|
||||
}
|
||||
|
||||
updateURLs();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateURLs();
|
||||
|
@ -121,59 +105,57 @@ export default function Urls() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={loading}/>
|
||||
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
|
||||
|
||||
<Dialog open={createOpen} onClose={() => setCreateOpen(false)}>
|
||||
<DialogTitle>Shorten URL</DialogTitle>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<DialogContent>
|
||||
<TextInput id='url' label='URL' formik={formik} />
|
||||
<TextInput id='vanity' label='Vanity' formik={formik} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCreateOpen(false)} color='inherit' autoFocus>Cancel</Button>
|
||||
<Button type='submit' color='inherit'>
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
{!urls.length ? (
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
pt={2}
|
||||
pb={3}
|
||||
<Modal
|
||||
opened={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title={<Title>Shorten URL</Title>}
|
||||
>
|
||||
<Typography variant='h4' sx={{ mb: 1 }}>No URLs <IconButton onClick={() => setCreateOpen(true)}><AddIcon/></IconButton></Typography>
|
||||
</Box>
|
||||
) : <Typography variant='h4' sx={{ mb: 1 }}>URLs <IconButton onClick={() => setCreateOpen(true)}><AddIcon/></IconButton></Typography>}
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<TextInput id='url' label='URL' {...form.getInputProps('url')} />
|
||||
<TextInput id='vanity' label='Vanity' {...form.getInputProps('vanity')} />
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Group position='right' mt={22}>
|
||||
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button type='submit'>Submit</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Group>
|
||||
<Title sx={{ marginBottom: 12 }}>URLs</Title>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setCreateOpen(true)}><PlusIcon/></ActionIcon>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid
|
||||
cols={4}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{urls.length ? urls.map(url => (
|
||||
<Grid item xs={12} sm={3} key={url.id}>
|
||||
<Card sx={{ maxWidth: '100%' }}>
|
||||
<CardHeader
|
||||
action={
|
||||
<>
|
||||
<IconButton aria-label='copy' onClick={() => copyURL(url)}>
|
||||
<Card key={url.id} sx={{ maxWidth: '100%' }} shadow='sm'>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Title>{url.vanity ?? url.id}</Title>
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
<ActionIcon href={url.url} component='a' target='_blank'><Link1Icon/></ActionIcon>
|
||||
<ActionIcon aria-label='copy' onClick={() => copyURL(url)}>
|
||||
<CopyIcon />
|
||||
</IconButton>
|
||||
<IconButton aria-label='delete' onClick={() => deleteURL(url)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
}
|
||||
title={url.vanity ?? url.id}
|
||||
subheader={<Link href={url.destination}>{url.destination}</Link>}
|
||||
/>
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='delete' onClick={() => deleteURL(url)}>
|
||||
<TrashIcon />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
</Grid>
|
||||
)) : null}
|
||||
</Grid>
|
||||
)) : [1,2,3,4,5,6,7].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={60} sx={{ borderRadius: 1 }}/>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,71 +1,29 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Card as MuiCard,
|
||||
CardHeader,
|
||||
Avatar,
|
||||
IconButton,
|
||||
Grid,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
} from '@mui/material';
|
||||
import { Delete as DeleteIcon, Add as AddIcon } from '@mui/icons-material';
|
||||
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import Alert from 'components/Alert';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useFormik } from 'formik';
|
||||
import { useForm } from '@mantine/hooks';
|
||||
import { Avatar, Modal, Title, TextInput, Group, Button, Card, Grid, ActionIcon, SimpleGrid, Switch, Skeleton } from '@mantine/core';
|
||||
import { Cross1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
|
||||
function Card({ user, handleDelete }) {
|
||||
return (
|
||||
<MuiCard sx={{ minWidth: 270 }}>
|
||||
<CardHeader
|
||||
avatar={<Avatar>{user.username[0]}</Avatar>}
|
||||
action={<IconButton onClick={() => handleDelete(user)}><DeleteIcon /></IconButton>}
|
||||
title={<Typography variant='h6'>{user.username}</Typography>}
|
||||
/>
|
||||
</MuiCard>
|
||||
);
|
||||
}
|
||||
|
||||
function TextInput({ id, label, formik, ...other }) {
|
||||
return (
|
||||
<TextField
|
||||
id={id}
|
||||
name={id}
|
||||
label={label}
|
||||
value={formik.values[id]}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched[id] && Boolean(formik.errors[id])}
|
||||
helperText={formik.touched[id] && formik.errors[id]}
|
||||
variant='standard'
|
||||
fullWidth
|
||||
sx={{ pb: 0.5 }}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateUserDialog({ open, setOpen, updateUsers, setSeverity, setMessage, setLoading, setAlertOpen }) {
|
||||
const formik = useFormik({
|
||||
function CreateUserModal({ open, setOpen, updateUsers }) {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
administrator: false,
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
});
|
||||
const notif = useNotifications();
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const cleanUsername = values.username.trim();
|
||||
const cleanPassword = values.password.trim();
|
||||
if (cleanUsername === '') return formik.setFieldError('username', 'Username can\'t be nothing');
|
||||
if (cleanPassword === '') return formik.setFieldError('password', 'Password can\'t be nothing');
|
||||
if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
|
||||
if (cleanPassword === '') return form.setFieldError('password', 'Password can\'t be nothing');
|
||||
|
||||
const data = {
|
||||
username: cleanUsername,
|
||||
|
@ -74,79 +32,61 @@ function CreateUserDialog({ open, setOpen, updateUsers, setSeverity, setMessage,
|
|||
};
|
||||
|
||||
setOpen(false);
|
||||
setLoading(true);
|
||||
const res = await useFetch('/api/auth/create', 'POST', data);
|
||||
if (res.error) {
|
||||
setSeverity('error');
|
||||
setMessage('Could\'nt create user: ' + res.error);
|
||||
setAlertOpen(true);
|
||||
} else {
|
||||
setSeverity('success');
|
||||
setMessage('Created user ' + res.username);
|
||||
setAlertOpen(true);
|
||||
updateUsers();
|
||||
}
|
||||
setLoading(false);
|
||||
},
|
||||
notif.showNotification({
|
||||
title: 'Failed to create user',
|
||||
message: res.error,
|
||||
icon: <TrashIcon />,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'Created user: ' + cleanUsername,
|
||||
message: '',
|
||||
icon: <PlusIcon />,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
|
||||
updateUsers();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open={open}
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
PaperProps={{
|
||||
elevation: 1,
|
||||
}}
|
||||
title={<Title>Create User</Title>}
|
||||
>
|
||||
<DialogTitle>
|
||||
Create User
|
||||
</DialogTitle>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<DialogContent>
|
||||
<TextInput id='username' label='Username' formik={formik} />
|
||||
<TextInput id='password' label='Password' formik={formik} type='password' />
|
||||
<FormControlLabel
|
||||
id='administrator'
|
||||
name='administrator'
|
||||
value={formik.values.administrator}
|
||||
onChange={formik.handleChange}
|
||||
control={<Switch />}
|
||||
label='Administrator?'
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
|
||||
<Button type='submit' color='inherit'>
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<TextInput id='username' label='Username' {...form.getInputProps('username')} />
|
||||
<TextInput id='password' label='Password' type='password' {...form.getInputProps('password')} />
|
||||
<Switch mt={12} id='administrator' label='Administrator' {...form.getInputProps('administrator')} />
|
||||
|
||||
<Group position='right' mt={22}>
|
||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button type='submit'>Create</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Users() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
const router = useRouter();
|
||||
const notif = useNotifications();
|
||||
|
||||
const [users, setUsers] = useState([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [severity, setSeverity] = useState('success');
|
||||
const [message, setMessage] = useState('Saved');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const updateUsers = async () => {
|
||||
setLoading(true);
|
||||
const us = await useFetch('/api/users');
|
||||
if (!us.error) {
|
||||
setUsers(us);
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
};
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (user) => {
|
||||
|
@ -154,15 +94,22 @@ export default function Users() {
|
|||
id: user.id,
|
||||
});
|
||||
if (res.error) {
|
||||
setMessage(`Could not delete ${user.username}`);
|
||||
setSeverity('error');
|
||||
setOpen(true);
|
||||
notif.showNotification({
|
||||
title: 'Failed to delete user',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
} else {
|
||||
setMessage(`Deleted user ${res.username}`);
|
||||
setSeverity('success');
|
||||
setOpen(true);
|
||||
updateUsers();
|
||||
notif.showNotification({
|
||||
title: 'User deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <TrashIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
updateUsers();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -171,17 +118,38 @@ export default function Users() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={loading}/>
|
||||
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
|
||||
<CreateUserDialog open={createOpen} setOpen={setCreateOpen} setSeverity={setSeverity} setMessage={setMessage} setLoading={setLoading} updateUsers={updateUsers} setAlertOpen={setOpen} />
|
||||
<Typography variant='h4' pb={2}>Users <IconButton onClick={() => setCreateOpen(true)}><AddIcon /></IconButton></Typography>
|
||||
<Grid container spacing={2}>
|
||||
{users.filter(x => x.username !== user.username).map((user, i) => (
|
||||
<Grid item xs={12} sm={3} key={i}>
|
||||
<Card user={user} handleDelete={handleDelete}/>
|
||||
</Grid>
|
||||
<CreateUserModal open={open} setOpen={setOpen} updateUsers={updateUsers} />
|
||||
<Group>
|
||||
<Title sx={{ marginBottom: 12 }}>Users</Title>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon/></ActionIcon>
|
||||
</Group>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{users.length ? users.filter(x => x.username !== user.username).map((user, i) => (
|
||||
<Card key={user.id} sx={{ maxWidth: '100%' }}>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Avatar color={user.administrator ? 'primary' : 'dark'}>{user.username[0]}</Avatar>
|
||||
<Title>{user.username}</Title>
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
<ActionIcon aria-label='delete' onClick={() => handleDelete(user)}>
|
||||
<TrashIcon />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)): [1,2,3,4].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
|
||||
</div>
|
||||
))}
|
||||
</Grid>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -16,7 +16,7 @@ export default function login() {
|
|||
setLoading(true);
|
||||
|
||||
const res = await useFetch('/api/user');
|
||||
if (res.error) return router.push('/auth/login');
|
||||
if (res.error) return router.push('/auth/login?url=' + router.route);
|
||||
|
||||
dispatch(updateUser(res));
|
||||
setUser(res);
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { CookieSerializeOptions } from 'cookie';
|
||||
import type { Image, Theme, User } from '@prisma/client';
|
||||
|
||||
import { serialize } from 'cookie';
|
||||
import { sign64, unsign64 } from '../util';
|
||||
|
@ -23,7 +22,6 @@ export type NextApiReq = NextApiRequest & {
|
|||
embedTitle: string;
|
||||
embedColor: string;
|
||||
systemTheme: string;
|
||||
customTheme?: Theme;
|
||||
administrator: boolean;
|
||||
id: number;
|
||||
password: string;
|
||||
|
@ -111,7 +109,6 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
|||
id: true,
|
||||
password: true,
|
||||
systemTheme: true,
|
||||
customTheme: true,
|
||||
token: true,
|
||||
username: true,
|
||||
},
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { Theme } from '@prisma/client';
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
export interface User {
|
||||
|
@ -8,7 +7,6 @@ export interface User {
|
|||
embedColor: string;
|
||||
embedSiteName: string;
|
||||
systemTheme: string;
|
||||
customTheme?: Theme;
|
||||
}
|
||||
|
||||
const initialState: User = null;
|
||||
|
|
|
@ -3,15 +3,36 @@
|
|||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'dark',
|
||||
primary: '#E6B450',
|
||||
secondary: '#FFEE99',
|
||||
error: '#F07178',
|
||||
warning: '#F29668',
|
||||
info: '#95E6CB',
|
||||
border: '#191e29',
|
||||
background: {
|
||||
main: '#0A0E14',
|
||||
paper: '#0D1016',
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'orange',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#0a0e14',
|
||||
hover: '#191e29',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#ffffff',
|
||||
'#47494E',
|
||||
'#6c707a',
|
||||
'#33353B',
|
||||
'#303238',
|
||||
'#2C2E34',
|
||||
'#25272D',
|
||||
'#0d1016',
|
||||
'#11141A',
|
||||
'#0D1016',
|
||||
],
|
||||
orange: [
|
||||
'#FFFFFF',
|
||||
'#FCF6EA',
|
||||
'#F9EDD4',
|
||||
'#F3DAA8',
|
||||
'#F2D69D',
|
||||
'#F0D192',
|
||||
'#EFCC87',
|
||||
'#EDC77C',
|
||||
'#EABE66',
|
||||
'#E6B450',
|
||||
],
|
||||
},
|
||||
});
|
|
@ -3,15 +3,24 @@
|
|||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'light',
|
||||
primary: '#FF9940',
|
||||
secondary: '#E6BA7E',
|
||||
error: '#F07171',
|
||||
warning: '#ED9366',
|
||||
info: '#95E6CB',
|
||||
border: '#e3e3e3',
|
||||
background: {
|
||||
main: '#FAFAFA',
|
||||
paper: '#FFFFFF',
|
||||
colorScheme: 'light',
|
||||
primaryColor: 'orange',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#FAFAFA',
|
||||
hover: '#FAFAFA',
|
||||
},
|
||||
colors: {
|
||||
orange: [
|
||||
'#FFFFFF',
|
||||
'#FCF6EA',
|
||||
'#F9EDD4',
|
||||
'#F3DAA8',
|
||||
'#F2D69D',
|
||||
'#F0D192',
|
||||
'#EFCC87',
|
||||
'#EDC77C',
|
||||
'#EABE66',
|
||||
'#E6B450',
|
||||
],
|
||||
},
|
||||
});
|
|
@ -3,15 +3,36 @@
|
|||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'dark',
|
||||
primary: '#FFCC66',
|
||||
secondary: '#FFD580',
|
||||
error: '#F28779',
|
||||
warning: '#F29E74',
|
||||
info: '#95E6CB',
|
||||
border: '#363c4d',
|
||||
background: {
|
||||
main: '#1F2430',
|
||||
paper: '#232834',
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'orange',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#1F2430',
|
||||
hover: '#2a2f3b',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#ffffff',
|
||||
'#91949A',
|
||||
'#6c707a',
|
||||
'#3F434E',
|
||||
'#313641',
|
||||
'#2A2F3B',
|
||||
'#2e333e',
|
||||
'#232834',
|
||||
'#11141A',
|
||||
'#0D1016',
|
||||
],
|
||||
orange: [
|
||||
'#FFFFFF',
|
||||
'#FCF6EA',
|
||||
'#F9EDD4',
|
||||
'#F3DAA8',
|
||||
'#F2D69D',
|
||||
'#F0D192',
|
||||
'#EFCC87',
|
||||
'#EDC77C',
|
||||
'#EABE66',
|
||||
'#E6B450',
|
||||
],
|
||||
},
|
||||
});
|
|
@ -1,15 +1,36 @@
|
|||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'dark',
|
||||
primary: '#2c39a6',
|
||||
secondary: '#7344e2',
|
||||
error: '#ff4141',
|
||||
warning: '#ff9800',
|
||||
info: '#2f6fb9',
|
||||
border: '#2b2b2b',
|
||||
background: {
|
||||
main: '#000000',
|
||||
paper: '#060606',
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'blue',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#000000',
|
||||
hover: '#2b2b2b',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#ffffff',
|
||||
'#A7A9AD',
|
||||
'#7B7E84',
|
||||
'#61646A',
|
||||
'#54575D',
|
||||
'#46494F',
|
||||
'#3C3F44',
|
||||
'#060606',
|
||||
'#141517',
|
||||
'#000000',
|
||||
],
|
||||
blue: [
|
||||
'#FFFFFF',
|
||||
'#7C7DC2',
|
||||
'#7778C0',
|
||||
'#6C6FBC',
|
||||
'#575DB5',
|
||||
'#4D54B2',
|
||||
'#424BAE',
|
||||
'#3742AA',
|
||||
'#323EA8',
|
||||
'#2C39A6',
|
||||
],
|
||||
},
|
||||
});
|
|
@ -1,15 +1,36 @@
|
|||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'dark',
|
||||
primary: '#2c39a6',
|
||||
secondary: '#7344e2',
|
||||
error: '#ff4141',
|
||||
warning: '#ff9800',
|
||||
info: '#2f6fb9',
|
||||
border: '#1b2541',
|
||||
background: {
|
||||
main: '#05070f',
|
||||
paper: '#0c101c',
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'blue',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#05070f',
|
||||
hover: '#181c28',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#FFFFFF',
|
||||
'#293747',
|
||||
'#6C7A8D',
|
||||
'#232F41',
|
||||
'#41566e',
|
||||
'#171F35',
|
||||
'#181c28',
|
||||
'#0c101c',
|
||||
'#060824',
|
||||
'#00001E',
|
||||
],
|
||||
blue: [
|
||||
'#FFFFFF',
|
||||
'#7C7DC2',
|
||||
'#7778C0',
|
||||
'#6C6FBC',
|
||||
'#575DB5',
|
||||
'#4D54B2',
|
||||
'#424BAE',
|
||||
'#3742AA',
|
||||
'#323EA8',
|
||||
'#2C39A6',
|
||||
],
|
||||
},
|
||||
});
|
|
@ -3,15 +3,36 @@
|
|||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'dark',
|
||||
primary: '#BD93F9',
|
||||
secondary: '#6272A4',
|
||||
error: '#FF5555',
|
||||
warning: '#FFB86C',
|
||||
info: '#8BE9FD',
|
||||
border: '#7D8096',
|
||||
background: {
|
||||
main: '#282A36',
|
||||
paper: '#44475A',
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'violet',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#282A36',
|
||||
hover: '#4e5062',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#FFFFFF',
|
||||
'#CED0D4',
|
||||
'#E8E8EB',
|
||||
'#D1D1D6',
|
||||
'#BABAC2',
|
||||
'#A2A3AD',
|
||||
'#4e5062',
|
||||
'#44475A',
|
||||
'#5C5E6F',
|
||||
'#44475A',
|
||||
],
|
||||
violet: [
|
||||
'#FFFFFF',
|
||||
'#F7F2FF',
|
||||
'#EFE4FE',
|
||||
'#EBDEFE',
|
||||
'#E7D7FD',
|
||||
'#DEC9FC',
|
||||
'#D6BCFC',
|
||||
'#CEAEFB',
|
||||
'#C6A1FA',
|
||||
'#BD93F9',
|
||||
],
|
||||
},
|
||||
});
|
|
@ -1,54 +1,5 @@
|
|||
import { createTheme as muiCreateTheme } from '@mui/material/styles';
|
||||
import { MantineThemeOverride } from '@mantine/core';
|
||||
|
||||
export interface ThemeOptions {
|
||||
type: 'dark' | 'light';
|
||||
primary: string;
|
||||
secondary: string;
|
||||
error: string;
|
||||
warning: string;
|
||||
info: string;
|
||||
border: string;
|
||||
background: ThemeOptionsBackground;
|
||||
}
|
||||
|
||||
export interface ThemeOptionsBackground {
|
||||
main: string;
|
||||
paper: string;
|
||||
}
|
||||
|
||||
export default function createTheme(o: ThemeOptions) {
|
||||
return muiCreateTheme({
|
||||
palette: {
|
||||
mode: o.type,
|
||||
primary: {
|
||||
main: o.primary,
|
||||
},
|
||||
secondary: {
|
||||
main: o.secondary,
|
||||
},
|
||||
background: {
|
||||
default: o.background.main,
|
||||
paper: o.background.paper,
|
||||
},
|
||||
error: {
|
||||
main: o.error,
|
||||
},
|
||||
warning: {
|
||||
main: o.warning,
|
||||
},
|
||||
info: {
|
||||
main: o.info,
|
||||
},
|
||||
divider: o.border,
|
||||
},
|
||||
components: {
|
||||
MuiTableHead: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: o.border,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
export default function createTheme(o: MantineThemeOverride) {
|
||||
return o;
|
||||
}
|
24
src/lib/themes/light_blue.ts
Normal file
24
src/lib/themes/light_blue.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
colorScheme: 'light',
|
||||
primaryColor: 'blue',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#FAFAFA',
|
||||
hover: '#FAFAFA',
|
||||
},
|
||||
colors: {
|
||||
blue: [
|
||||
'#FFFFFF',
|
||||
'#7C7DC2',
|
||||
'#7778C0',
|
||||
'#6C6FBC',
|
||||
'#575DB5',
|
||||
'#4D54B2',
|
||||
'#424BAE',
|
||||
'#3742AA',
|
||||
'#323EA8',
|
||||
'#2C39A6',
|
||||
],
|
||||
},
|
||||
});
|
|
@ -1,15 +1,36 @@
|
|||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'dark',
|
||||
primary: '#3498db',
|
||||
secondary: '#7344e2',
|
||||
error: '#db5b5b',
|
||||
warning: '#ff9800',
|
||||
info: '#2f6fb9',
|
||||
border: '#14161b',
|
||||
background: {
|
||||
main: '#1b1d24',
|
||||
paper: '#202329',
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'blue',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#1b1d24',
|
||||
hover: '#3c3f44',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#FFFFFF',
|
||||
'#C8C8CA',
|
||||
'#F5F5F5',
|
||||
'#909194',
|
||||
'#585A5F',
|
||||
'#4A4D52',
|
||||
'#3C3F44',
|
||||
'#202329',
|
||||
'#272A30',
|
||||
'#202329',
|
||||
],
|
||||
blue: [
|
||||
'#FFFFFF',
|
||||
'#E6F3FB',
|
||||
'#CDE6F6',
|
||||
'#B4D9F2',
|
||||
'#9ACCED',
|
||||
'#8EC6EB',
|
||||
'#81BFE9',
|
||||
'#67B2E4',
|
||||
'#4EA5E0',
|
||||
'#3498DB',
|
||||
],
|
||||
},
|
||||
});
|
|
@ -3,15 +3,36 @@
|
|||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'dark',
|
||||
primary: '#81A1C1',
|
||||
secondary: '#88C0D0',
|
||||
error: '#BF616A',
|
||||
warning: '#EBCB8B',
|
||||
info: '#5E81AC',
|
||||
border: '#565e70',
|
||||
background: {
|
||||
main: '#2E3440',
|
||||
paper: '#3B4252',
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'blue',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#2E3440',
|
||||
hover: '#6c727e',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#FFFFFF',
|
||||
'#CED0D4',
|
||||
'#B6B9BF',
|
||||
'#9DA1A9',
|
||||
'#858A94',
|
||||
'#6C727E',
|
||||
'#606673',
|
||||
'#3B4252',
|
||||
'#484E5D',
|
||||
'#3B4252',
|
||||
],
|
||||
blue: [
|
||||
'#FFFFFF',
|
||||
'#E0E8F0',
|
||||
'#C0D0E0',
|
||||
'#B9CBDD',
|
||||
'#B1C5D9',
|
||||
'#A1B9D1',
|
||||
'#99B3CD',
|
||||
'#91ADC9',
|
||||
'#89A7C5',
|
||||
'#81A1C1',
|
||||
],
|
||||
},
|
||||
});
|
|
@ -1,17 +0,0 @@
|
|||
// https://github.com/AlphaNecron/
|
||||
// https://github.com/arcticicestudio/nord
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'light',
|
||||
primary: '#81A1C1',
|
||||
secondary: '#88C0D0',
|
||||
error: '#BF616A',
|
||||
warning: '#EBCB8B',
|
||||
info: '#5E81AC',
|
||||
border: '#989fab',
|
||||
background: {
|
||||
main: '#D8DEE9',
|
||||
paper: '#E5E9F0',
|
||||
},
|
||||
});
|
|
@ -1,15 +1,37 @@
|
|||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'dark',
|
||||
primary: '#5294e2',
|
||||
secondary: '#7344e2',
|
||||
error: '##f04a50',
|
||||
warning: '#ff9800',
|
||||
info: '#2f6fb9',
|
||||
border: '#4a4c54',
|
||||
background: {
|
||||
main: '#32343d',
|
||||
paper: '#262830',
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'blue',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#32343d',
|
||||
hover: '#34363d',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#FFFFFF',
|
||||
'#C9CACC',
|
||||
'#F5F5F5',
|
||||
'#78797E',
|
||||
'#5D5E64',
|
||||
'#42434A',
|
||||
'#34363D',
|
||||
'#262830',
|
||||
'#2A2C34',
|
||||
'#262830',
|
||||
],
|
||||
|
||||
blue: [
|
||||
'#FFFFFF',
|
||||
'#E6F3FB',
|
||||
'#CDE6F6',
|
||||
'#B4D9F2',
|
||||
'#9ACCED',
|
||||
'#8EC6EB',
|
||||
'#81BFE9',
|
||||
'#67B2E4',
|
||||
'#4EA5E0',
|
||||
'#3498DB',
|
||||
],
|
||||
},
|
||||
});
|
|
@ -1,16 +1,18 @@
|
|||
import React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { Box, Text } from '@mantine/core';
|
||||
|
||||
export default function FourOhFour() {
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
minHeight='100vh'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant='h2'>404 - Not Found</Typography>
|
||||
<Text size='xl'>404 - Not Found</Text>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import Head from 'next/head';
|
||||
import { GetServerSideProps } from 'next';
|
||||
import { Box } from '@mui/material';
|
||||
import { Box, useMantineTheme } from '@mantine/core';
|
||||
import config from 'lib/config';
|
||||
import prisma from 'lib/prisma';
|
||||
import { getFile } from '../../server/util';
|
||||
import { parse } from 'lib/clientUtils';
|
||||
import * as exts from '../../scripts/exts';
|
||||
import { Prism } from '@mantine/prism';
|
||||
import ZiplineTheming from 'components/Theming';
|
||||
|
||||
export default function EmbeddedImage({ image, user, normal }) {
|
||||
export default function EmbeddedImage({ image, user }) {
|
||||
const dataURL = (route: string) => `${route}/${image.file}`;
|
||||
|
||||
// reapply date from workaround
|
||||
|
@ -40,10 +43,12 @@ export default function EmbeddedImage({ image, user, normal }) {
|
|||
<title>{image.file}</title>
|
||||
</Head>
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
minHeight='100vh'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<img src={dataURL('/r')} alt={dataURL('/r')} id='image_content' />
|
||||
</Box>
|
||||
|
@ -114,19 +119,28 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
|||
//@ts-ignore workaround because next wont allow date
|
||||
image.created_at = image.created_at.toString();
|
||||
|
||||
const prismRender = Object.keys(exts).includes(image.file.split('.').pop());
|
||||
// let prismRenderCode;/
|
||||
// if (prismRender) prismRenderCode = (await getFile(config.uploader.directory, id)).toString();
|
||||
if (prismRender) return {
|
||||
redirect: {
|
||||
destination: `/code/${image.file}`,
|
||||
permanent: true,
|
||||
},
|
||||
};
|
||||
|
||||
if (!image.mimetype.startsWith('image')) {
|
||||
const data = await getFile(config.uploader.directory, id);
|
||||
if (!data) return { notFound: true };
|
||||
|
||||
context.res.end(data);
|
||||
return { props: {} };
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
image,
|
||||
user,
|
||||
normal: config.uploader.route,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,33 +1,19 @@
|
|||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import Head from 'next/head';
|
||||
import Theming from 'components/Theming';
|
||||
import { useStore } from 'lib/redux/store';
|
||||
import ZiplineTheming from 'components/Theming';
|
||||
|
||||
export default function MyApp({ Component, pageProps }) {
|
||||
const store = useStore();
|
||||
|
||||
React.useEffect(() => {
|
||||
const jssStyles = document.querySelector('#jss-server-side');
|
||||
if (jssStyles) jssStyles.parentElement.removeChild(jssStyles);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Head>
|
||||
<title>{Component.title}</title>
|
||||
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width' />
|
||||
</Head>
|
||||
<Theming
|
||||
Component={Component}
|
||||
pageProps={pageProps}
|
||||
/>
|
||||
<ZiplineTheming Component={Component} pageProps={pageProps} />
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
MyApp.propTypes = {
|
||||
Component: PropTypes.elementType.isRequired,
|
||||
pageProps: PropTypes.object.isRequired,
|
||||
};
|
|
@ -1,11 +1,11 @@
|
|||
import React from 'react';
|
||||
import Document, { Html, Head, Main, NextScript } from 'next/document';
|
||||
import { createGetInitialProps } from '@mantine/next';
|
||||
|
||||
const getInitialProps = createGetInitialProps();
|
||||
|
||||
class MyDocument extends Document {
|
||||
static async getInitialProps(ctx) {
|
||||
const initialProps = await Document.getInitialProps(ctx);
|
||||
return { ...initialProps };
|
||||
}
|
||||
static getInitialProps = getInitialProps;
|
||||
|
||||
render() {
|
||||
return (
|
||||
|
|
|
@ -32,8 +32,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||
const valid = await checkPassword(password, user.password);
|
||||
if (!valid) return res.forbid('Wrong password');
|
||||
|
||||
// 604800 seconds is 1 week
|
||||
res.setCookie('user', user.id, { sameSite: true, maxAge: 604800, path: '/' });
|
||||
res.setCookie('user', user.id, { sameSite: true, expires: new Date(Date.now() + (6.048e+8 * 2)), path: '/' });
|
||||
|
||||
Logger.get('user').info(`User ${user.username} (${user.id}) logged in`);
|
||||
|
||||
|
|
|
@ -51,24 +51,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||
data: { systemTheme: req.body.systemTheme },
|
||||
});
|
||||
|
||||
if (req.body.customTheme) {
|
||||
if (user.customTheme) await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
customTheme: {
|
||||
update: {
|
||||
...req.body.customTheme,
|
||||
},
|
||||
},
|
||||
},
|
||||
}); else await prisma.theme.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
...req.body.customTheme,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const newUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: Number(user.id),
|
||||
|
@ -82,7 +64,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||
images: false,
|
||||
password: false,
|
||||
systemTheme: true,
|
||||
customTheme: true,
|
||||
token: true,
|
||||
username: true,
|
||||
},
|
||||
|
|
|
@ -21,6 +21,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||
created_at: true,
|
||||
file: true,
|
||||
mimetype: true,
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -33,7 +33,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||
token: true,
|
||||
embedColor: true,
|
||||
embedTitle: true,
|
||||
customTheme: true,
|
||||
systemTheme: true,
|
||||
},
|
||||
});
|
||||
|
|
17
src/pages/api/version.ts
Normal file
17
src/pages/api/version.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { readFile } from 'fs/promises';
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const pkg = JSON.parse(await readFile('package.json', 'utf8'));
|
||||
|
||||
const re = await fetch('https://raw.githubusercontent.com/diced/zipline/trunk/package.json');
|
||||
const upstreamPkg = await re.json();
|
||||
|
||||
return res.json({
|
||||
local: pkg.version,
|
||||
upstream: upstreamPkg.version,
|
||||
});
|
||||
}
|
||||
|
||||
export default withZipline(handler);
|
|
@ -1,82 +1,96 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Typography, Box, TextField, Stack, Button, styled } from '@mui/material';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Alert from 'components/Alert';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import TextInput from 'components/input/TextInput';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useFormik } from 'formik';
|
||||
import { useForm } from '@mantine/hooks';
|
||||
import { TextInput, Button, Center, Title, Box, Badge, Tooltip } from '@mantine/core';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { Cross1Icon, DownloadIcon } from '@modulz/radix-icons';
|
||||
|
||||
export default function Login() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [severity, setSeverity] = useState('success');
|
||||
const [message, setMessage] = useState('');
|
||||
const [loadingOpen, setLoadingOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const notif = useNotifications();
|
||||
const [versions, setVersions] = React.useState<{ upstream: string, local: string }>(null);
|
||||
|
||||
const formik = useFormik({
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
onSubmit: async values => {
|
||||
});
|
||||
|
||||
const onSubmit = async values => {
|
||||
const username = values.username.trim();
|
||||
const password = values.password.trim();
|
||||
|
||||
if (username === '') return formik.setFieldError('username', 'Username can\'t be nothing');
|
||||
if (username === '') return form.setFieldError('username', 'Username can\'t be nothing');
|
||||
|
||||
setLoadingOpen(true);
|
||||
const res = await useFetch('/api/auth/login', 'POST', {
|
||||
username, password,
|
||||
});
|
||||
|
||||
if (res.error) {
|
||||
setOpen(true);
|
||||
setSeverity('error');
|
||||
setMessage(res.error);
|
||||
setLoadingOpen(false);
|
||||
} else {
|
||||
setOpen(true);
|
||||
setSeverity('success');
|
||||
setMessage('Logged in');
|
||||
router.push('/dashboard');
|
||||
}
|
||||
},
|
||||
notif.showNotification({
|
||||
title: 'Login Failed',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
} else {
|
||||
router.push(router.query.url as string || '/dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const a = await fetch('/api/user');
|
||||
if (a.ok) router.push('/dashboard');
|
||||
else {
|
||||
const v = await useFetch('/api/version');
|
||||
setVersions(v);
|
||||
if (v.local !== v.upstream) {
|
||||
notif.showNotification({
|
||||
title: 'Update available',
|
||||
message: `A new version of Zipline is available. You are running ${v.local} and the latest version is ${v.upstream}.`,
|
||||
icon: <DownloadIcon />,
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert open={open} setOpen={setOpen} severity={severity} message={message} />
|
||||
<Backdrop open={loadingOpen} />
|
||||
<Box
|
||||
display='flex'
|
||||
height='screen'
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
sx={{ height: '24rem' }}
|
||||
>
|
||||
<Stack>
|
||||
<Typography variant='h3' textAlign='center'>
|
||||
Zipline
|
||||
</Typography>
|
||||
<Center sx={{ height: '100vh' }}>
|
||||
<div>
|
||||
<Title align='center'>Zipline</Title>
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<TextInput size='lg' id='username' label='Username' {...form.getInputProps('username')} />
|
||||
<TextInput size='lg' id='password' label='Password' type='password' {...form.getInputProps('password')} />
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput formik={formik} id='username' label='Username' />
|
||||
<TextInput formik={formik} id='password' label='Password' type='password' />
|
||||
<Box my={2}>
|
||||
<Button variant='contained' fullWidth type='submit'>
|
||||
Login
|
||||
</Button>
|
||||
</Box>
|
||||
<Button size='lg' type='submit' fullWidth mt={12}>Login</Button>
|
||||
</form>
|
||||
</Stack>
|
||||
</div>
|
||||
</Center>
|
||||
<Box
|
||||
sx={{
|
||||
zIndex: 99,
|
||||
position: 'fixed',
|
||||
bottom: '10px',
|
||||
right: '20px',
|
||||
|
||||
}}
|
||||
>
|
||||
{versions && (
|
||||
<Tooltip
|
||||
wrapLines
|
||||
width={220}
|
||||
transition='rotate-left'
|
||||
transitionDuration={200}
|
||||
label={versions.local !== versions.upstream ? 'Looks like you are running an outdated version of Zipline. Please update to the latest version.' : 'You are running the latest version of Zipline.'}
|
||||
>
|
||||
<Badge radius='md' size='lg' variant='dot' color={versions.local !== versions.upstream ? 'red' : 'primary'}>{versions.local}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Backdrop, CircularProgress } from '@mui/material';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
|
||||
export default function Logout() {
|
||||
const router = useRouter();
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
|
@ -18,12 +20,7 @@ export default function Logout() {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<Backdrop
|
||||
sx={{ color: '#fff', zIndex: t => t.zIndex.drawer + 1 }}
|
||||
open
|
||||
>
|
||||
<CircularProgress color='inherit' />
|
||||
</Backdrop>
|
||||
<LoadingOverlay visible={visible} />
|
||||
);
|
||||
}
|
||||
|
||||
|
|
23
src/pages/code/[id].tsx
Normal file
23
src/pages/code/[id].tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import exts from '../../../scripts/exts';
|
||||
import { Prism } from '@mantine/prism';
|
||||
|
||||
export default function Code() {
|
||||
const [prismRenderCode, setPrismRenderCode] = React.useState('');
|
||||
const router = useRouter();
|
||||
const { id } = router.query as { id: string };
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetch('/r/' + id);
|
||||
if (id && !res.ok) router.push('/404');
|
||||
const data = await res.text();
|
||||
if (id) setPrismRenderCode(data);
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
return id && prismRenderCode ? (
|
||||
<Prism sx={t => ({ height: '100vh', backgroundColor: t.colors.dark[8] })} withLineNumbers language={exts[id.split('.').pop()]}>{prismRenderCode}</Prism>
|
||||
) : null;
|
||||
}
|
|
@ -11,8 +11,6 @@ export default function FilesPage() {
|
|||
return (
|
||||
<Layout
|
||||
user={user}
|
||||
loading={loading}
|
||||
noPaper={false}
|
||||
>
|
||||
<Files />
|
||||
</Layout>
|
||||
|
|
|
@ -5,14 +5,11 @@ import Dashboard from 'components/pages/Dashboard';
|
|||
|
||||
export default function DashboardPage() {
|
||||
const { user, loading } = useLogin();
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
user={user}
|
||||
loading={loading}
|
||||
noPaper={false}
|
||||
>
|
||||
<Dashboard />
|
||||
</Layout>
|
||||
|
|
|
@ -11,8 +11,6 @@ export default function ManagePage() {
|
|||
return (
|
||||
<Layout
|
||||
user={user}
|
||||
loading={loading}
|
||||
noPaper={false}
|
||||
>
|
||||
<Manage />
|
||||
</Layout>
|
||||
|
|
19
src/pages/dashboard/stats.tsx
Normal file
19
src/pages/dashboard/stats.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import Layout from 'components/Layout';
|
||||
import Stats from 'components/pages/Stats';
|
||||
|
||||
export default function StatsPage() {
|
||||
const { user, loading } = useLogin();
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
user={user}
|
||||
>
|
||||
<Stats />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
StatsPage.title = 'Zipline - Stats';
|
|
@ -13,8 +13,6 @@ export default function UploadPage({ route }) {
|
|||
return (
|
||||
<Layout
|
||||
user={user}
|
||||
loading={loading}
|
||||
noPaper={false}
|
||||
>
|
||||
<Upload route={route}/>
|
||||
</Layout>
|
||||
|
|
|
@ -11,8 +11,6 @@ export default function UrlsPage() {
|
|||
return (
|
||||
<Layout
|
||||
user={user}
|
||||
loading={loading}
|
||||
noPaper={false}
|
||||
>
|
||||
<Urls />
|
||||
</Layout>
|
||||
|
|
|
@ -11,8 +11,6 @@ export default function UsersPage() {
|
|||
return (
|
||||
<Layout
|
||||
user={user}
|
||||
loading={loading}
|
||||
noPaper={false}
|
||||
>
|
||||
<Users />
|
||||
</Layout>
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.push('/dashboard');
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
}
|
Loading…
Reference in a new issue