feat(v3.4.0): switch from Material-UI to Mantine! (#127)

This commit is contained in:
dicedtomato 2022-02-26 17:19:02 -08:00 committed by GitHub
parent 4d9a22e82c
commit 16d2014bfb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 2317 additions and 3463 deletions

46
docker-compose.dev.yml Normal file
View 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:

View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "zip3", "name": "zip3",
"version": "3.3.2", "version": "3.4.0",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "NODE_ENV=development node server", "dev": "NODE_ENV=development node server",
@ -10,15 +10,21 @@
"migrate:dev": "prisma migrate dev --create-only", "migrate:dev": "prisma migrate dev --create-only",
"start": "node server", "start": "node server",
"lint": "next lint", "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": { "dependencies": {
"@emotion/react": "^11.4.0",
"@emotion/styled": "^11.3.0",
"@iarna/toml": "2.2.5", "@iarna/toml": "2.2.5",
"@mui/icons-material": "^5.0.0", "@mantine/core": "^3.6.9",
"@mui/material": "^5.0.2", "@mantine/dropzone": "^3.6.9",
"@mui/styles": "^5.0.1", "@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/client": "^3.9.2",
"@prisma/migrate": "^3.9.2", "@prisma/migrate": "^3.9.2",
"@prisma/sdk": "^3.9.2", "@prisma/sdk": "^3.9.2",
@ -26,17 +32,14 @@
"argon2": "^0.28.2", "argon2": "^0.28.2",
"colorette": "^1.2.2", "colorette": "^1.2.2",
"cookie": "^0.4.1", "cookie": "^0.4.1",
"copy-to-clipboard": "^3.3.1",
"fecha": "^4.2.1", "fecha": "^4.2.1",
"formik": "^2.2.9",
"multer": "^1.4.2", "multer": "^1.4.2",
"next": "^12.1.0", "next": "^12.1.0",
"prisma": "^3.9.2", "prisma": "^3.9.2",
"react": "17.0.2", "react": "^17.0.2",
"react-color": "^2.19.3", "react-dom": "^17.0.2",
"react-dom": "17.0.2",
"react-dropzone": "^11.3.2",
"react-redux": "^7.2.4", "react-redux": "^7.2.4",
"react-table": "^7.7.0",
"redux": "^4.1.0", "redux": "^4.1.0",
"redux-thunk": "^2.3.0", "redux-thunk": "^2.3.0",
"uuid": "^8.3.2", "uuid": "^8.3.2",
@ -46,6 +49,7 @@
"@types/cookie": "^0.4.0", "@types/cookie": "^0.4.0",
"@types/multer": "^1.4.6", "@types/multer": "^1.4.6",
"@types/node": "^15.12.2", "@types/node": "^15.12.2",
"babel-plugin-import": "^1.13.3",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-config-next": "11.0.0", "eslint-config-next": "11.0.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",

View file

@ -13,8 +13,7 @@ model User {
password String password String
token String token String
administrator Boolean @default(false) administrator Boolean @default(false)
systemTheme String @default("dark_blue") systemTheme String @default("system")
customTheme Theme?
embedTitle String? embedTitle String?
embedColor String @default("#2f3136") embedColor String @default("#2f3136")
embedSiteName String? @default("{image.file} • {user.name}") embedSiteName String? @default("{image.file} • {user.name}")
@ -23,21 +22,6 @@ model User {
urls Url[] 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 { enum ImageFormat {
UUID UUID
DATE DATE

38
scripts/exts.js Normal file
View file

@ -0,0 +1,38 @@
// https://github.com/toptal/haste-server/blob/master/static/application.js#L167-L174
// Popular extension map
module.exports = {
rb: 'ruby',
py: 'python',
pl: 'perl',
php: 'php',
scala: 'scala',
go: 'go',
xml: 'xml',
html: 'xml',
htm: 'xml',
css: 'css',
js: 'javascript',
json: 'json',
vbs: 'vbscript',
lua: 'lua',
pas: 'delphi',
java: 'java',
cpp: 'cpp',
cc: 'cpp',
m: 'objectivec',
vala: 'vala',
sql: 'sql',
sm: 'smalltalk',
lisp: 'lisp',
ini: 'ini',
diff: 'diff',
bash: 'bash',
sh: 'bash',
tex: 'tex',
erl: 'erlang',
hs: 'haskell',
md: 'markdown',
txt: '',
coffee: 'coffee',
swift: 'swift',
};

View file

@ -1,18 +1,16 @@
const next = require('next').default; const next = require('next').default;
const defaultConfig = require('next/dist/server/config-shared').defaultConfig;
const { createServer } = require('http'); const { createServer } = require('http');
const { stat, mkdir } = require('fs/promises'); const { mkdir } = require('fs/promises');
const { extname } = require('path'); const { extname } = require('path');
const validateConfig = require('./validateConfig'); const validateConfig = require('./validateConfig');
const Logger = require('../src/lib/logger'); const Logger = require('../src/lib/logger');
const readConfig = require('../src/lib/readConfig'); const readConfig = require('../src/lib/readConfig');
const mimes = require('../scripts/mimes'); 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 { PrismaClient } = require('@prisma/client');
const { version } = require('../package.json'); const { version } = require('../package.json');
const nextConfig = require('../next.config'); const exts = require('../scripts/exts');
const serverLog = Logger.get('server'); const serverLog = Logger.get('server');
const webLog = Logger.get('web');
serverLog.info(`starting zipline@${version} server`); serverLog.info(`starting zipline@${version} server`);
@ -42,7 +40,6 @@ async function run() {
quiet: !dev, quiet: !dev,
hostname: config.core.host, hostname: config.core.host,
port: config.core.port, port: config.core.port,
conf: Object.assign(defaultConfig, nextConfig),
}); });
await app.prepare(); await app.prepare();
@ -51,15 +48,15 @@ async function run() {
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const srv = createServer(async (req, res) => { const srv = createServer(async (req, res) => {
const parts = req.url.split('/');
if (!parts[2] || parts[2] === '') return;
if (req.url.startsWith('/r')) { if (req.url.startsWith('/r')) {
const parts = req.url.split('/');
if (!parts[2] || parts[2] === '') return;
let image = await prisma.image.findFirst({ let image = await prisma.image.findFirst({
where: { where: {
OR: [ OR: [
{ file: parts[2] }, { file: parts[2] },
{ invisible: { invis: decodeURI(parts[2]) } }, { invisible:{ invis: decodeURI(parts[2]) } },
], ],
}, },
select: { select: {
@ -70,19 +67,17 @@ async function run() {
}, },
}); });
image && await prisma.image.update({ if (!image) {
where: { id: image.id }, const data = await getFile(config.uploader.directory, parts[2]);
data: { views: { increment: 1 } }, if (!data) return app.render404(req, res);
});
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'; const mimetype = mimes[extname(parts[2])] ?? 'application/octet-stream';
res.setHeader('Content-Type', mimetype); res.setHeader('Content-Type', mimetype);
res.end(data); res.end(data);
} else { // 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({ await prisma.image.update({
where: { id: image.id }, where: { id: image.id },
data: { views: { increment: 1 } }, data: { views: { increment: 1 } },
@ -91,14 +86,14 @@ async function run() {
res.end(data); res.end(data);
} }
} else if (req.url.startsWith(config.uploader.route)) { } else if (req.url.startsWith(config.uploader.route)) {
const data = await getFile(config.uploader.directory, parts[2]); const parts = req.url.split('/');
if (!data) return app.render404(req, res); if (!parts[2] || parts[2] === '') return;
let image = await prisma.image.findFirst({ let image = await prisma.image.findFirst({
where: { where: {
OR: [ OR: [
{ file: parts[2] }, { file: parts[2] },
{ invisible: { invis: decodeURI(parts[2]) } }, { invisible:{ invis: decodeURI(parts[2]) } },
], ],
}, },
select: { select: {
@ -110,18 +105,25 @@ async function run() {
}, },
}); });
image && await prisma.image.update({ if (!image) {
where: { id: image.id }, const data = await getFile(config.uploader.directory, parts[2]);
data: { views: { increment: 1 } }, if (!data) return app.render404(req, res);
});
if (!image) { // raw image
const mimetype = mimes[extname(parts[2])] ?? 'application/octet-stream'; const mimetype = mimes[extname(parts[2])] ?? 'application/octet-stream';
res.setHeader('Content-Type', mimetype); res.setHeader('Content-Type', mimetype);
res.end(data); res.end(data);
} else if (image.embed) { // embed image } else if (image.embed) {
handle(req, res); 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.setHeader('Content-Type', image.mimetype);
res.end(data); res.end(data);
} }
@ -137,6 +139,7 @@ async function run() {
process.exit(1); process.exit(1);
}); });
srv.on('listening', () => { srv.on('listening', () => {
serverLog.info(`listening on ${config.core.host}:${config.core.port}`); serverLog.info(`listening on ${config.core.host}:${config.core.port}`);
}); });

View file

@ -7,7 +7,7 @@ const validator = object({
host: string().default('0.0.0.0'), host: string().default('0.0.0.0'),
port: number().default(3000), port: number().default(3000),
database_url: string().required(), database_url: string().required(),
logger: boolean().default(true), logger: boolean().default(false),
stats_interval: number().default(1800), stats_interval: number().default(1800),
}).required(), }).required(),
uploader: object({ uploader: object({

View file

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

View file

@ -1,16 +1,8 @@
import React from 'react'; import React from 'react';
import { import { LoadingOverlay } from '@mantine/core';
Backdrop as MuiBackdrop,
CircularProgress,
} from '@mui/material';
export default function Backdrop({ open }) { export default function Backdrop({ open }) {
return ( return (
<MuiBackdrop <LoadingOverlay visible={open} />
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={open}
>
<CircularProgress color='inherit' />
</MuiBackdrop>
); );
} }

View file

@ -1,19 +1,16 @@
import React from 'react'; import React from 'react';
import { import {
Card as MuiCard, Card as MCard,
CardContent, Title,
Typography, } from '@mantine/core';
} from '@mui/material';
export default function Card(props) { export default function Card(props) {
const { name, children, ...other } = props; const { name, children, ...other } = props;
return ( return (
<MuiCard sx={{ minWidth: '100%' }} {...other}> <MCard padding='md' shadow='sm' {...other}>
<CardContent> <Title order={2}>{name}</Title>
<Typography variant='h3'>{name}</Typography> {children}
{children} </MCard>
</CardContent>
</MuiCard>
); );
} }

View file

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

View file

@ -1,83 +1,94 @@
import React, { useState } from 'react'; 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 useFetch from 'hooks/useFetch';
import { Button, Card, Group, Image as MImage, Modal, Title } from '@mantine/core';
import { useNotifications } from '@mantine/notifications';
import { CopyIcon, Cross1Icon, StarIcon, TrashIcon } from '@modulz/radix-icons';
import { useClipboard } from '@mantine/hooks';
export default function Image({ image, updateImages }) { export default function Image({ image, updateImages }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [t] = useState(image.mimetype.split('/')[0]); const [t] = useState(image.mimetype.split('/')[0]);
const notif = useNotifications();
const clipboard = useClipboard();
const handleDelete = async () => { const handleDelete = async () => {
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id }); const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
if (!res.error) updateImages(true); if (!res.error) {
updateImages(true);
notif.showNotification({
title: 'Image Deleted',
message: '',
color: 'green',
icon: <TrashIcon />,
});
} else {
notif.showNotification({
title: 'Failed to delete image',
message: res.error,
color: 'red',
icon: <Cross1Icon />,
});
}
setOpen(false); setOpen(false);
}; };
const handleCopy = () => { const handleCopy = () => {
copy(`${window.location.protocol}//${window.location.host}${image.url}`); clipboard.copy(`${window.location.protocol}//${window.location.host}${image.url}`);
setOpen(false); setOpen(false);
notif.showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
}; };
const handleFavorite = async () => { const handleFavorite = async () => {
const data = await useFetch('/api/user/files', 'PATCH', { id: image.id, favorite: !image.favorite }); const data = await useFetch('/api/user/files', 'PATCH', { id: image.id, favorite: !image.favorite });
if (!data.error) updateImages(true); if (!data.error) updateImages(true);
notif.showNotification({
title: 'Image is now ' + (!image.favorite ? 'favorited' : 'unfavorited'),
message: '',
icon: <StarIcon />,
});
}; };
const Type = (props) => { const Type = (props) => {
return { return {
'video': <video controls {...props} />, 'video': <video controls {...props} />,
// eslint-disable-next-line jsx-a11y/alt-text 'image': <MImage {...props} />,
'image': <img {...props} />,
'audio': <audio controls {...props} />, 'audio': <audio controls {...props} />,
}[t]; }[t];
}; };
return ( return (
<> <>
<Card sx={{ maxWidth: '100%' }}> <Modal
<CardActionArea sx={t === 'audio' ? { justifyContent: 'center', display: 'flex', alignItems: 'center' } : {}}> opened={open}
<CardMedia onClose={() => setOpen(false)}
sx={{ height: 320, fontSize: 70, width: '100%' }} title={<Title>{image.file}</Title>}
image={image.url} >
title={image.file} <Type
component={t === 'audio' ? AudioIcon : t} // this is done because audio without controls is hidden src={image.url}
alt={image.file}
/>
<Group position='right' mt={22}>
<Button onClick={handleCopy}>Copy</Button>
<Button onClick={handleDelete}>Delete</Button>
<Button onClick={handleFavorite}>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
</Group>
</Modal>
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
<Card.Section>
<Type
sx={{ maxHeight: 320, fontSize: 70, width: '100%', cursor: 'pointer' }}
style={{ maxHeight: 320, fontSize: 70, width: '100%', cursor: 'pointer' }}
src={image.url}
alt={image.file}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
/> />
</CardActionArea> </Card.Section>
</Card> </Card>
<Dialog
open={open}
onClose={() => setOpen(false)}
>
<DialogTitle id='alert-dialog-title'>
{image.file}
</DialogTitle>
<DialogContent>
<Type
style={{ width: '100%' }}
src={image.url}
alt={image.url}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleDelete} color='inherit'>Delete</Button>
<Button onClick={handleCopy} color='inherit'>Copy URL</Button>
<Button onClick={handleFavorite} color='inherit'>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
</DialogActions>
</Dialog>
</> </>
); );
} }

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

View file

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

View file

@ -4,7 +4,7 @@ import React, { forwardRef } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import NextLink from 'next/link'; import NextLink from 'next/link';
import MuiLink from '@mui/material/Link'; import { Text } from '@mantine/core';
export const NextLinkComposed = forwardRef(function NextLinkComposed(props: any, ref) { export const NextLinkComposed = forwardRef(function NextLinkComposed(props: any, ref) {
const { to, linkAs, href, replace, scroll, passHref, shallow, prefetch, locale, ...other } = const { to, linkAs, href, replace, scroll, passHref, shallow, prefetch, locale, ...other } =
@ -50,10 +50,10 @@ const Link = forwardRef(function Link(props: any, ref) {
if (isExternal) { if (isExternal) {
if (noLinkStyle) { if (noLinkStyle) {
return <a className={className} href={href} ref={ref} {...other} />; return <Text variant='link' component='a' className={className} href={href} ref={ref} {...other} />;
} }
return <MuiLink className={className} href={href} ref={ref} {...other} />; return <Text component='a' variant='link' href={href} ref={ref} {...other} />;
} }
if (noLinkStyle) { if (noLinkStyle) {
@ -61,8 +61,9 @@ const Link = forwardRef(function Link(props: any, ref) {
} }
return ( return (
<MuiLink <Text
component={NextLinkComposed} component={NextLinkComposed}
variant='link'
linkAs={linkAs} linkAs={linkAs}
className={className} className={className}
ref={ref} ref={ref}

View file

@ -1,77 +1,96 @@
import React from 'react'; import React, { useEffect } from 'react';
import { ThemeProvider } from '@emotion/react';
import { CssBaseline } from '@mui/material';
// themes // themes
import dark_blue from 'lib/themes/dark_blue'; import dark_blue from 'lib/themes/dark_blue';
import light_blue from 'lib/themes/light_blue';
import dark from 'lib/themes/dark'; import dark from 'lib/themes/dark';
import ayu_dark from 'lib/themes/ayu_dark'; import ayu_dark from 'lib/themes/ayu_dark';
import ayu_mirage from 'lib/themes/ayu_mirage'; import ayu_mirage from 'lib/themes/ayu_mirage';
import ayu_light from 'lib/themes/ayu_light'; import ayu_light from 'lib/themes/ayu_light';
import nord from 'lib/themes/nord'; import nord from 'lib/themes/nord';
import polar from 'lib/themes/polar';
import dracula from 'lib/themes/dracula'; import dracula from 'lib/themes/dracula';
import matcha_dark_azul from 'lib/themes/matcha_dark_azul'; import matcha_dark_azul from 'lib/themes/matcha_dark_azul';
import qogir_dark from 'lib/themes/qogir_dark'; import qogir_dark from 'lib/themes/qogir_dark';
import { useStoreSelector } from 'lib/redux/store'; import { useStoreSelector } from 'lib/redux/store';
import createTheme from 'lib/themes'; import { MantineProvider, MantineThemeOverride } from '@mantine/core';
import { ModalsProvider } from '@mantine/modals';
import { NotificationsProvider } from '@mantine/notifications';
import { useColorScheme } from '@mantine/hooks';
export const themes = { export const themes = {
'dark_blue': dark_blue, system: (colorScheme: 'dark' | 'light') => colorScheme === 'dark' ? dark_blue : light_blue,
'dark': dark, dark_blue,
'ayu_dark': ayu_dark, light_blue,
'ayu_mirage': ayu_mirage, dark,
'ayu_light': ayu_light, ayu_dark,
'nord': nord, ayu_mirage,
'polar': polar, ayu_light,
'dracula': dracula, nord,
'matcha_dark_azul': matcha_dark_azul, dracula,
'qogir_dark': qogir_dark, matcha_dark_azul,
qogir_dark,
}; };
export const friendlyThemeName = { export const friendlyThemeName = {
'system': 'System Theme',
'dark_blue': 'Dark Blue', 'dark_blue': 'Dark Blue',
'light_blue': 'Light Blue',
'dark': 'Very Dark', 'dark': 'Very Dark',
'ayu_dark': 'Ayu Dark', 'ayu_dark': 'Ayu Dark',
'ayu_mirage': 'Ayu Mirage', 'ayu_mirage': 'Ayu Mirage',
'ayu_light': 'Ayu Light', 'ayu_light': 'Ayu Light',
'nord': 'Nord', 'nord': 'Nord',
'polar': 'Polar',
'dracula': 'Dracula', 'dracula': 'Dracula',
'matcha_dark_azul': 'Matcha Dark Azul', 'matcha_dark_azul': 'Matcha Dark Azul',
'qogir_dark': 'Qogir Dark', 'qogir_dark': 'Qogir Dark',
}; };
export default function ZiplineTheming({ Component, pageProps }) { export default function ZiplineTheming({ Component, pageProps, ...props }) {
let t;
const user = useStoreSelector(state => state.user); const user = useStoreSelector(state => state.user);
if (!user) t = themes.dark_blue; const colorScheme = useColorScheme();
else {
if (user.customTheme) { let theme: MantineThemeOverride;
t = createTheme({
type: 'dark', if (!user) theme = themes.system(colorScheme);
primary: user.customTheme.primary, else if (user.systemTheme === 'system') theme = themes.system(colorScheme);
secondary: user.customTheme.secondary, else theme = themes[user.systemTheme] ?? themes.system(colorScheme);
error: user.customTheme.error,
warning: user.customTheme.warning, useEffect(() => {
info: user.customTheme.info, document.documentElement.style.setProperty('color-scheme', theme.colorScheme);
border: user.customTheme.border, }, [user, theme]);
background: {
main: user.customTheme.mainBackground,
paper: user.customTheme.paperBackground,
},
});
} else {
t = themes[user.systemTheme] ?? themes.dark_blue;
}
}
return ( return (
<ThemeProvider theme={t}> <MantineProvider
<CssBaseline /> withGlobalStyles
<Component {...pageProps} /> withNormalizeCSS
</ThemeProvider> theme={theme}
styles={{
AppShell: t => ({
root: {
backgroundColor: t.other.AppShell_backgroundColor,
},
}),
Popover: {
inner: {
width: 200,
},
},
Accordion: {
itemTitle: {
border: 0,
},
itemOpened: {
border: 0,
},
},
}}
>
<ModalsProvider>
<NotificationsProvider>
{props.children ? props.children : <Component {...pageProps} />}
</NotificationsProvider>
</ModalsProvider>
</MantineProvider>
); );
} }

View file

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

View file

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

View file

@ -1,32 +1,15 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
Button,
ButtonGroup,
Typography,
Grid,
Skeleton,
CardActionArea,
CardMedia,
Card as MuiCard,
} from '@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 Card from 'components/Card';
import Alert from 'components/Alert'; import Image from 'components/Image';
import copy from 'copy-to-clipboard'; import ImagesTable from 'components/ImagesTable';
import useFetch from 'lib/hooks/useFetch'; import useFetch from 'lib/hooks/useFetch';
import { useStoreSelector } from 'lib/redux/store'; import { useStoreSelector } from 'lib/redux/store';
import { Box, Text, Table, Skeleton, Title, SimpleGrid } from '@mantine/core';
import { randomId, useClipboard } from '@mantine/hooks';
import Link from 'components/Link';
import { CopyIcon, Cross1Icon, TrashIcon } from '@modulz/radix-icons';
import { useNotifications } from '@mantine/notifications';
type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify'; type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify';
@ -44,50 +27,34 @@ export function bytesToRead(bytes: number) {
return `${bytes.toFixed(1)} ${units[num]}`; return `${bytes.toFixed(1)} ${units[num]}`;
} }
const columns = [
{ id: 'file', label: 'Name', minWidth: 170, align: 'inherit' as Aligns },
{ id: 'mimetype', label: 'Type', minWidth: 100, align: 'inherit' as Aligns },
{
id: 'created_at',
label: 'Date',
minWidth: 170,
align: 'right' as Aligns,
format: (value) => new Date(value).toLocaleString(),
},
];
function StatText({ children }) { function StatText({ children }) {
return <Typography variant='h5' color='GrayText'>{children}</Typography>; return <Text color='gray' size='xl'>{children}</Text>;
} }
function StatTable({ rows, columns }) { function StatTable({ rows, columns }) {
return ( return (
<TableContainer sx={{ pt: 1 }}> <Box sx={{ pt: 1 }}>
<Table sx={{ minWidth: 100 }} size='small'> <Table highlightOnHover>
<TableHead> <thead>
<TableRow> <tr>
{columns.map(col => ( {columns.map(col => (
<TableCell key={col.name} sx={{ borderColor: t => t.palette.divider }}>{col.name}</TableCell> <th key={randomId()}>{col.name}</th>
))} ))}
</TableRow> </tr>
</TableHead> </thead>
<TableBody> <tbody>
{rows.map(row => ( {rows.map(row => (
<TableRow <tr key={randomId()}>
hover
key={row.username}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
{columns.map(col => ( {columns.map(col => (
<TableCell key={col.id} sx={{ borderColor: t => t.palette.divider }}> <td key={randomId()}>
{col.format ? col.format(row[col.id]) : row[col.id]} {col.format ? col.format(row[col.id]) : row[col.id]}
</TableCell> </td>
))} ))}
</TableRow> </tr>
))} ))}
</TableBody> </tbody>
</Table> </Table>
</TableContainer> </Box>
); );
} }
@ -96,35 +63,51 @@ export default function Dashboard() {
const [images, setImages] = useState([]); const [images, setImages] = useState([]);
const [recent, setRecent] = useState([]); const [recent, setRecent] = useState([]);
const [page, setPage] = useState(0);
const [stats, setStats] = useState(null); const [stats, setStats] = useState(null);
const [rowsPerPage, setRowsPerPage] = useState(10); const clipboard = useClipboard();
const notif = useNotifications();
const [open, setOpen] = useState(false);
const [severity, setSeverity] = useState('success');
const [message, setMessage] = useState('Saved');
const updateImages = async () => { const updateImages = async () => {
const imgs = await useFetch('/api/user/files'); const imgs = await useFetch('/api/user/files');
const recent = await useFetch('/api/user/recent?filter=media'); const recent = await useFetch('/api/user/recent?filter=media');
const stts = await useFetch('/api/stats'); const stts = await useFetch('/api/stats');
setImages(imgs); setImages(imgs.map(x => ({ ...x, created_at: new Date(x.created_at).toLocaleString() })));
setStats(stts); setStats(stts);
setRecent(recent); setRecent(recent);
}; };
const handleChangePage = (event, newPage) => { const deleteImage = async ({ original }) => {
setPage(newPage); const res = await useFetch('/api/user/files', 'DELETE', { id: original.id });
if (!res.error) {
updateImages();
notif.showNotification({
title: 'Image Deleted',
message: '',
color: 'green',
icon: <TrashIcon />,
});
} else {
notif.showNotification({
title: 'Failed to delete image',
message: res.error,
color: 'red',
icon: <Cross1Icon />,
});
}
}; };
const handleChangeRowsPerPage = event => { const copyImage = async ({ original }) => {
setRowsPerPage(+event.target.value); clipboard.copy(`${window.location.protocol}//${window.location.host}${original.url}`);
setPage(0); notif.showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
}; };
const handleDelete = async image => { const viewImage = async ({ original }) => {
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id }); window.open(`${window.location.protocol}//${window.location.host}${original.url}`);
if (!res.error) updateImages();
}; };
useEffect(() => { useEffect(() => {
@ -133,117 +116,75 @@ export default function Dashboard() {
return ( 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> <Title>Recent Files</Title>
<Typography color='GrayText' pb={2}>You have <b>{images.length ? images.length : '...'}</b> files</Typography> <SimpleGrid
cols={4}
<Typography variant='h4'>Recent Files</Typography> spacing='lg'
<Grid container spacing={4} py={2}> breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{recent.length ? recent.map(image => ( {recent.length ? recent.map(image => (
<Grid item xs={12} sm={3} key={image.id}> // eslint-disable-next-line jsx-a11y/alt-text
<MuiCard sx={{ minWidth: '100%' }}> <Image key={randomId()} image={image} updateImages={updateImages} />
<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 => ( )) : [1,2,3,4].map(x => (
<Grid item xs={12} sm={3} key={x}> <div key={x}>
<Skeleton variant='rectangular' width='100%' height={220} sx={{ borderRadius: 1 }}/> <Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
</Grid> </div>
))} ))}
</Grid> </SimpleGrid>
<Typography variant='h4'>Stats</Typography>
<Grid container spacing={4} py={2}> <Title mt='md'>Stats</Title>
<Grid item xs={12} sm={4}> <Text>View more stats here <Link href='/dashboard/stats'>here</Link>.</Text>
<Card name='Size' sx={{ height: '100%' }}> <SimpleGrid
<StatText>{stats ? stats.size : <Skeleton variant='text' />}</StatText> cols={3}
<Typography variant='h3'>Average Size</Typography> spacing='lg'
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton variant='text' />}</StatText> breakpoints={[
</Card> { maxWidth: 'sm', cols: 1, spacing: 'sm' },
</Grid> ]}
<Grid item xs={12} sm={4}> >
<Card name='Images' sx={{ height: '100%' }}> <Card name='Size' sx={{ height: '100%' }}>
<StatText>{stats ? stats.count : <Skeleton variant='text' />}</StatText> <StatText>{stats ? stats.size : <Skeleton height={8} />}</StatText>
<Typography variant='h3'>Views</Typography> <Title order={2}>Average Size</Title>
<StatText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton variant='text' />}</StatText> <StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</StatText>
</Card> </Card>
</Grid> <Card name='Images' sx={{ height: '100%' }}>
<Grid item xs={12} sm={4}> <StatText>{stats ? stats.count : <Skeleton height={8} />}</StatText>
<Card name='Users' sx={{ height: '100%' }}> <Title order={2}>Views</Title>
<StatText>{stats ? stats.count_users : <Skeleton variant='text' />}</StatText> <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>
</Grid> <Card name='Users' sx={{ height: '100%' }}>
</Grid> <StatText>{stats ? stats.count_users : <Skeleton height={8} />}</StatText>
<Card name='Images' sx={{ my: 2 }} elevation={0} variant='outlined'> </Card>
<Link href='/dashboard/files' pb={2}>View Files</Link> </SimpleGrid>
<TableContainer sx={{ maxHeight: 440 }}>
<Table size='small'> <ImagesTable
<TableHead> columns={[
<TableRow> { accessor: 'file', Header: 'Name', minWidth: 170, align: 'inherit' as Aligns },
{columns.map(column => ( { accessor: 'mimetype', Header: 'Type', minWidth: 100, align: 'inherit' as Aligns },
<TableCell { accessor: 'created_at', Header: 'Date' },
key={column.id} ]}
align={column.align} data={images}
sx={{ minWidth: column.minWidth, borderColor: t => t.palette.divider }} deleteImage={deleteImage}
> copyImage={copyImage}
{column.label} viewImage={viewImage}
</TableCell> />
))}
<TableCell sx={{ minWidth: 200, borderColor: t => t.palette.divider }} align='right'> {/* <Title mt='md'>Files</Title>
Actions <Text>View previews of your files in the <Link href='/dashboard/files'>browser</Link>.</Text>
</TableCell> <ReactTable
</TableRow> columns={[
</TableHead> { accessor: 'file', Header: 'Name', minWidth: 170, align: 'inherit' as Aligns },
<TableBody> { accessor: 'mimetype', Header: 'Type', minWidth: 100, align: 'inherit' as Aligns },
{images { accessor: 'created_at', Header: 'Date' },
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) ]}
.map(row => { data={images}
return ( pagination
<TableRow hover role='checkbox' tabIndex={-1} key={row.id}> />
{columns.map(column => { <Card name='Files per User' mt={22}>
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} />
</Card>
<Card name='Files per User' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'>
<StatTable <StatTable
columns={[ columns={[
{ id: 'username', name: 'Name' }, { id: 'username', name: 'Name' },
@ -251,14 +192,14 @@ export default function Dashboard() {
]} ]}
rows={stats ? stats.count_by_user : []} /> rows={stats ? stats.count_by_user : []} />
</Card> </Card>
<Card name='Types' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'> <Card name='Types' mt={22}>
<StatTable <StatTable
columns={[ columns={[
{ id: 'mimetype', name: 'Type' }, { id: 'mimetype', name: 'Type' },
{ id: 'count', name: 'Count' }, { id: 'count', name: 'Count' },
]} ]}
rows={stats ? stats.types_count : []} /> rows={stats ? stats.types_count : []} />
</Card> </Card> */}
</> </>
); );
} }

View file

@ -1,27 +1,24 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Grid, Pagination, Box, Typography, Accordion, AccordionSummary, AccordionDetails } from '@mui/material';
import { ExpandMore } from '@mui/icons-material';
import Backdrop from 'components/Backdrop';
import ZiplineImage from 'components/Image'; import ZiplineImage from 'components/Image';
import useFetch from 'hooks/useFetch'; import useFetch from 'hooks/useFetch';
import { Box, Accordion, Pagination, Title, SimpleGrid, Skeleton, Group, ActionIcon } from '@mantine/core';
import { PlusIcon } from '@modulz/radix-icons';
import Link from 'next/link';
export default function Files() { export default function Files() {
const [pages, setPages] = useState([]); const [pages, setPages] = useState([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [favoritePages, setFavoritePages] = useState([]); const [favoritePages, setFavoritePages] = useState([]);
const [favoritePage, setFavoritePage] = useState(1); const [favoritePage, setFavoritePage] = useState(1);
const [loading, setLoading] = useState(true);
const updatePages = async favorite => { const updatePages = async favorite => {
setLoading(true);
const pages = await useFetch('/api/user/files?paged=true&filter=media'); const pages = await useFetch('/api/user/files?paged=true&filter=media');
if (favorite) { if (favorite) {
const fPages = await useFetch('/api/user/files?paged=true&favorite=media'); const fPages = await useFetch('/api/user/files?paged=true&favorite=media');
setFavoritePages(fPages); setFavoritePages(fPages);
} }
setPages(pages); setPages(pages);
setLoading(false);
}; };
useEffect(() => { useEffect(() => {
@ -30,59 +27,76 @@ export default function Files() {
return ( return (
<> <>
<Backdrop open={loading}/> <Group>
{!pages.length ? ( <Title sx={{ marginBottom: 12 }}>Files</Title>
<Box <Link href='/dashboard/upload' passHref>
display='flex' <ActionIcon component='a' variant='filled' color='primary'><PlusIcon/></ActionIcon>
justifyContent='center' </Link>
alignItems='center' </Group>
pt={2} <Accordion
pb={3} offsetIcon={false}
> sx={t => ({
<Typography variant='h4'>No Files</Typography> marginTop: 2,
</Box> border: '1px solid',
) : <Typography variant='h4'>Files</Typography>} marginBottom: 12,
{favoritePages.length ? ( borderColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0] ,
<Accordion sx={{ my: 2, border: 1, borderColor: t => t.palette.divider }} elevation={0}> })}
<AccordionSummary expandIcon={<ExpandMore />}> >
<Typography variant='h4'>Favorite Files</Typography> <Accordion.Item label={<Title>Favorite Files</Title>}>
</AccordionSummary> <SimpleGrid
<AccordionDetails> cols={3}
<Grid container spacing={2}> spacing='lg'
{favoritePages.length ? favoritePages[(favoritePage - 1) ?? 0].map(image => ( breakpoints={[
<Grid item xs={12} sm={3} key={image.id}> { maxWidth: 'sm', cols: 1, spacing: 'sm' },
<ZiplineImage image={image} updateImages={() => updatePages(true)} /> ]}
</Grid> >
)) : null} {favoritePages.length ? favoritePages[(favoritePage - 1) ?? 0].map(image => (
</Grid> <div key={image.id}>
{favoritePages.length ? ( <ZiplineImage image={image} updateImages={() => updatePages(true)} />
<Box </div>
display='flex' )) : null}
justifyContent='center' </SimpleGrid>
alignItems='center' <Box
pt={2} sx={{
> display: 'flex',
<Pagination count={favoritePages.length} page={favoritePage} onChange={(_, v) => setFavoritePage(v)}/> justifyContent: 'center',
</Box> alignItems: 'center',
) : null} paddingTop: 12,
</AccordionDetails> paddingBottom: 3,
</Accordion> }}
) : null} >
<Grid container spacing={2}> <Pagination total={favoritePages.length} page={favoritePage} onChange={setFavoritePage}/>
</Box>
</Accordion.Item>
</Accordion>
<SimpleGrid
cols={3}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{pages.length ? pages[(page - 1) ?? 0].map(image => ( {pages.length ? pages[(page - 1) ?? 0].map(image => (
<Grid item xs={12} sm={3} key={image.id}> <div key={image.id}>
<ZiplineImage image={image} updateImages={updatePages} /> <ZiplineImage image={image} updateImages={() => updatePages(true)} />
</Grid> </div>
)) : null} )) : [1,2,3,4].map(x => (
</Grid> <div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
</div>
))}
</SimpleGrid>
{pages.length ? ( {pages.length ? (
<Box <Box
display='flex' sx={{
justifyContent='center' display: 'flex',
alignItems='center' justifyContent: 'center',
pt={2} alignItems: 'center',
paddingTop: 12,
paddingBottom: 3,
}}
> >
<Pagination count={pages.length} page={page} onChange={(_, v) => setPage(v)}/> <Pagination total={pages.length} page={page} onChange={setPage}/>
</Box> </Box>
) : null} ) : null}
</> </>

View file

@ -1,72 +1,21 @@
import React, { useState } from 'react'; import React from 'react';
import { Button, Box, Typography, MenuItem, Tooltip } from '@mui/material';
import Download from '@mui/icons-material/Download';
import { useFormik } from 'formik';
import * as yup from 'yup';
import useFetch from 'hooks/useFetch'; import useFetch from 'hooks/useFetch';
import Backdrop from 'components/Backdrop';
import Alert from 'components/Alert';
import TextInput from 'components/input/TextInput';
import Select from 'components/input/Select';
import Link from 'components/Link'; import Link from 'components/Link';
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store'; import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
import { updateUser } from 'lib/redux/reducers/user'; import { updateUser } from 'lib/redux/reducers/user';
import { useRouter } from 'next/router'; import { useForm } from '@mantine/hooks';
import { Tooltip, TextInput, Button, Text, Title, Group, ColorInput } from '@mantine/core';
const validationSchema = yup.object({ import { DownloadIcon } from '@modulz/radix-icons';
username: yup
.string()
.required('Username is required'),
});
const themeValidationSchema = yup.object({
type: yup
.string()
.required('Type (dark, light) is required is required'),
primary: yup
.string()
.required('Primary color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
secondary: yup
.string()
.required('Secondary color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
error: yup
.string()
.required('Error color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
warning: yup
.string()
.required('Warning color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
info: yup
.string()
.required('Info color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
border: yup
.string()
.required('Border color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
mainBackground: yup
.string()
.required('Main Background is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
paperBackground: yup
.string()
.required('Paper Background is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
});
function VarsTooltip({ children }) { function VarsTooltip({ children }) {
return ( return (
<Tooltip title={ <Tooltip position='top' placement='center' color='' label={
<> <>
<Typography><b>{'{image.file}'}</b> - file name</Typography> <Text><b>{'{image.file}'}</b> - file name</Text>
<Typography><b>{'{image.mimetype}'}</b> - mimetype</Typography> <Text><b>{'{image.mimetype}'}</b> - mimetype</Text>
<Typography><b>{'{image.id}'}</b> - id of the image</Typography> <Text><b>{'{image.id}'}</b> - id of the image</Text>
<Typography><b>{'{user.name}'}</b> - your username</Typography> <Text><b>{'{user.name}'}</b> - your username</Text>
visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables 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() { export default function Manage() {
const user = useStoreSelector(state => state.user); const user = useStoreSelector(state => state.user);
const dispatch = useStoreDispatch(); const dispatch = useStoreDispatch();
const router = useRouter();
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [severity, setSeverity] = useState('success');
const [message, setMessage] = useState('Saved');
const genShareX = (withEmbed: boolean = false, withZws: boolean = false) => { const genShareX = (withEmbed: boolean = false, withZws: boolean = false) => {
const config = { const config = {
@ -111,7 +54,7 @@ export default function Manage() {
pseudoElement.parentNode.removeChild(pseudoElement); pseudoElement.parentNode.removeChild(pseudoElement);
}; };
const formik = useFormik({ const form = useForm({
initialValues: { initialValues: {
username: user.username, username: user.username,
password: '', password: '',
@ -119,142 +62,58 @@ export default function Manage() {
embedColor: user.embedColor, embedColor: user.embedColor,
embedSiteName: user.embedSiteName ?? '', embedSiteName: user.embedSiteName ?? '',
}, },
validationSchema,
onSubmit: async values => {
const cleanUsername = values.username.trim();
const cleanPassword = values.password.trim();
const cleanEmbedTitle = values.embedTitle.trim();
const cleanEmbedColor = values.embedColor.trim();
const cleanEmbedSiteName = values.embedSiteName.trim();
if (cleanUsername === '') return formik.setFieldError('username', 'Username can\'t be nothing');
setLoading(true);
const data = {
username: cleanUsername,
password: cleanPassword === '' ? null : cleanPassword,
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor,
embedSiteName: cleanEmbedSiteName === '' ? null : cleanEmbedSiteName,
};
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({ const onSubmit = async values => {
initialValues: { const cleanUsername = values.username.trim();
type: user.customTheme?.type || 'dark', const cleanPassword = values.password.trim();
primary: user.customTheme?.primary || '', const cleanEmbedTitle = values.embedTitle.trim();
secondary: user.customTheme?.secondary || '', const cleanEmbedColor = values.embedColor.trim();
error: user.customTheme?.error || '', const cleanEmbedSiteName = values.embedSiteName.trim();
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) { if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
setLoading(false);
setMessage('An error occured'); const data = {
setSeverity('error'); username: cleanUsername,
setOpen(true); password: cleanPassword === '' ? null : cleanPassword,
} else { embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
dispatch(updateUser(newUser)); embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor,
router.replace(router.pathname); embedSiteName: cleanEmbedSiteName === '' ? null : cleanEmbedSiteName,
setLoading(false); };
setMessage('Saved theme');
setSeverity('success'); const newUser = await useFetch('/api/user', 'PATCH', data);
setOpen(true);
} if (newUser.error) {
}, } else {
}); dispatch(updateUser(newUser));
}
};
return ( return (
<> <>
<Backdrop open={loading}/> <Title>Manage User</Title>
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
<Typography variant='h4'>Manage User</Typography>
<VarsTooltip> <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> </VarsTooltip>
<form onSubmit={formik.handleSubmit}> <form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput fullWidth id='username' label='Username' formik={formik} /> <TextInput id='username' label='Username' {...form.getInputProps('username')} />
<TextInput fullWidth id='password' label='Password' formik={formik} type='password' /> <TextInput id='password' label='Password'type='password' {...form.getInputProps('password')} />
<TextInput fullWidth id='embedTitle' label='Embed Title' formik={formik} /> <TextInput id='embedTitle' label='Embed Title' {...form.getInputProps('embedTitle')} />
<TextInput fullWidth id='embedColor' label='Embed Color' formik={formik} /> <ColorInput id='embedColor' label='Embed Color' {...form.getInputProps('embedColor')} />
<TextInput fullWidth id='embedSiteName' label='Embed Site Name' formik={formik} /> <TextInput id='embedSiteName' label='Embed Site Name' {...form.getInputProps('embedSiteName')} />
<Box <Group position='right' sx={{ paddingTop: 12 }}>
display='flex'
justifyContent='right'
alignItems='right'
pt={2}
>
<Button <Button
variant='contained'
type='submit' type='submit'
>Save User</Button> >Save User</Button>
</Box> </Group>
</form> </form>
<Typography variant='h4' py={2}>Manage Theme</Typography>
<form onSubmit={customThemeFormik.handleSubmit}> <Title sx={{ paddingTop: 12, paddingBottom: 12 }}>ShareX Config</Title>
<Select <Group>
id='type' <Button onClick={() => genShareX(false)} rightIcon={<DownloadIcon />}>ShareX Config</Button>
name='type' <Button onClick={() => genShareX(true)} rightIcon={<DownloadIcon />}>ShareX Config with Embed</Button>
label='Type' <Button onClick={() => genShareX(false, true)} rightIcon={<DownloadIcon />}>ShareX Config with ZWS</Button>
value={customThemeFormik.values['type']} </Group>
onChange={customThemeFormik.handleChange}
error={customThemeFormik.touched['type'] && Boolean(customThemeFormik.errors['type'])}
variant='standard'
fullWidth
>
<MenuItem value='dark'>Dark Theme</MenuItem>
<MenuItem value='light'>Light Theme</MenuItem>
</Select>
<TextInput 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>
</> </>
); );
} }

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

View file

@ -1,39 +1,65 @@
import React, { useEffect, useState } from 'react'; 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 { useStoreSelector } from 'lib/redux/store';
import CenteredBox from 'components/CenteredBox';
import copy from 'copy-to-clipboard';
import Link from 'components/Link'; import Link from 'components/Link';
import { Button, Group, Text, useMantineTheme } from '@mantine/core';
import { ImageIcon, UploadIcon, CrossCircledIcon } from '@modulz/radix-icons';
import { Dropzone } from '@mantine/dropzone';
import { useNotifications } from '@mantine/notifications';
import { useClipboard } from '@mantine/hooks';
function ImageUploadIcon({ status, ...props }) {
if (status.accepted) {
return <UploadIcon {...props} />;
}
if (status.rejected) {
return <CrossCircledIcon {...props} />;
}
return <ImageIcon {...props} />;
}
function getIconColor(status, theme) {
return status.accepted
? theme.colors[theme.primaryColor][6]
: status.rejected
? theme.colors.red[6]
: theme.colorScheme === 'dark'
? theme.colors.dark[0]
: theme.black;
}
export default function Upload({ route }) { export default function Upload({ route }) {
const theme = useMantineTheme();
const notif = useNotifications();
const clipboard = useClipboard();
const user = useStoreSelector(state => state.user); const user = useStoreSelector(state => state.user);
const [files, setFiles] = useState([]); const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [severity, setSeverity] = useState('success');
const [message, setMessage] = useState('Saved');
useEffect(() => { useEffect(() => {
window.addEventListener('paste', (e: ClipboardEvent) => { window.addEventListener('paste', (e: ClipboardEvent) => {
const item = Array.from(e.clipboardData.items).find(x => /^image/.test(x.type)); const item = Array.from(e.clipboardData.items).find(x => /^image/.test(x.type));
const blob = item.getAsFile(); const blob = item.getAsFile();
setFiles([...files, new File([blob], blob.name, { type: blob.type })]); setFiles([...files, new File([blob], blob.name, { type: blob.type })]);
notif.showNotification({
title: 'Image Imported',
message: '',
});
}); });
}); });
const handleUpload = async () => { const handleUpload = async () => {
const body = new FormData(); const body = new FormData();
for (let i = 0; i !== files.length; ++i) body.append('file', files[i]); for (let i = 0; i !== files.length; ++i) body.append('file', files[i]);
setLoading(true); const id = notif.showNotification({
title: 'Uploading Images...',
message: '',
loading: true,
});
const res = await fetch('/api/upload', { const res = await fetch('/api/upload', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -43,63 +69,52 @@ export default function Upload({ route }) {
}); });
const json = await res.json(); const json = await res.json();
if (res.ok && json.error === undefined) { if (res.ok && json.error === undefined) {
setOpen(true); notif.updateNotification(id, {
setSeverity('success'); title: 'Upload Successful',
message: <>Copied first image to clipboard! <br/>{json.files.map(x => (<Link key={x} href={x}>{x}<br/></Link>))}</>,
//@ts-ignore color: 'green',
setMessage(<>Copied first image to clipboard! <br/>{json.files.map(x => (<Link key={x} href={x}>{x}<br/></Link>))}</>); icon: <UploadIcon />,
copy(json.url); });
clipboard.copy(json.url);
setFiles([]); setFiles([]);
} else { } else {
setOpen(true); notif.updateNotification(id, {
setSeverity('error'); title: 'Upload Failed',
setMessage('Could not upload file: ' + json.error); message: json.error,
color: 'red',
icon: <CrossCircledIcon />,
});
} }
setLoading(false);
}; };
return ( return (
<> <>
<Backdrop open={loading}/> <Dropzone
<Alert open={open} setOpen={setOpen} message={message} severity={severity} /> onDrop={(f) => setFiles([...files, ...f])}
>
{(status) => (
<>
<Group position='center' spacing='xl' style={{ minHeight: 220, pointerEvents: 'none' }}>
<ImageUploadIcon
status={status}
style={{ width: 80, height: 80, color: getIconColor(status, theme) }}
/>
<Typography variant='h4' pb={2}>Upload file</Typography> <div>
<Dropzone onDrop={acceptedFiles => setFiles([...files, ...acceptedFiles])}> <Text size='xl' inline>
{({getRootProps, getInputProps}) => ( Drag images here or click to select files
<CardActionArea> </Text>
<Paper </div>
elevation={0} </Group>
variant='outlined' <Group position='center' spacing='xl' style={{ pointerEvents: 'none' }}>
sx={{ {files.map(file => (<Text key={file.name} weight='bold'>{file.name}</Text>))}
justifyContent: 'center', </Group>
alignItems: 'center', </>
display: 'block',
p: 5,
}}
{...getRootProps()}
>
<input {...getInputProps()} />
<CenteredBox><UploadIcon sx={{ fontSize: 100 }} /></CenteredBox>
<CenteredBox><Typography variant='h5'>Drag an image or click to upload an image.</Typography></CenteredBox>
{files.map(file => (
<CenteredBox key={file.name}><Typography variant='h6'>{file.name}</Typography></CenteredBox>
))}
</Paper>
</CardActionArea>
)} )}
</Dropzone> </Dropzone>
<Group position='right'>
<Box <Button leftIcon={<UploadIcon />} mt={12} onClick={handleUpload}>Upload</Button>
display='flex' </Group>
justifyContent='right'
alignItems='right'
pt={2}
>
<Button
variant='contained'
onClick={handleUpload}
>Upload</Button>
</Box>
</> </>
); );
} }

View file

@ -1,179 +1,161 @@
import React, { useEffect, useState } from 'react'; 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 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 { useStoreSelector } from 'lib/redux/store';
import * as yup from 'yup'; import { useClipboard, useForm } from '@mantine/hooks';
import { CopyIcon, Cross1Icon, Link1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
function TextInput({ id, label, formik, ...other }) { import { useNotifications } from '@mantine/notifications';
return ( import { Modal, Title, Group, Button, Box, Card, TextInput, ActionIcon, SimpleGrid, Skeleton } from '@mantine/core';
<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}
/>
);
}
export default function Urls() { export default function Urls() {
const user = useStoreSelector(state => state.user); const user = useStoreSelector(state => state.user);
const notif = useNotifications();
const clipboard = useClipboard();
const [loading, setLoading] = useState(true);
const [urls, setURLS] = useState([]); const [urls, setURLS] = useState([]);
const [open, setOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [severity, setSeverity] = useState('success');
const [message, setMessage] = useState('Deleted');
const updateURLs = async () => { const updateURLs = async () => {
setLoading(true);
const urls = await useFetch('/api/user/urls'); const urls = await useFetch('/api/user/urls');
setURLS(urls); setURLS(urls);
setLoading(false);
}; };
const deleteURL = async u => { const deleteURL = async u => {
const url = await useFetch('/api/user/urls', 'DELETE', { id: u.id }); const url = await useFetch('/api/user/urls', 'DELETE', { id: u.id });
if (url.error) { if (url.error) {
setSeverity('error'); notif.showNotification({
setMessage('Error: ' + url.error); title: 'Failed to delete URL',
setOpen(true); message: url.error,
icon: <TrashIcon />,
color: 'red',
});
} else { } else {
setSeverity('success'); notif.showNotification({
setMessage(`Deleted ${u.vanity ?? u.id}`); title: 'Deleted URL',
setOpen(true); message: '',
icon: <Cross1Icon />,
color: 'green',
});
} }
updateURLs(); updateURLs();
}; };
const copyURL = u => { const copyURL = u => {
copy(`${window.location.protocol}//${window.location.host}${u.url}`); clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
setSeverity('success'); notif.showNotification({
setMessage(`Copied URL: ${window.location.protocol}//${window.location.host}${u.url}`); title: 'Copied to clipboard',
setOpen(true); message: '',
icon: <CopyIcon />,
});
}; };
const formik = useFormik({ const form = useForm({
initialValues: { initialValues: {
url: '', url: '',
vanity: '', vanity: '',
}, },
validationSchema: yup.object({
}),
onSubmit: async (values) => {
const cleanURL = values.url.trim();
const cleanVanity = values.vanity.trim();
if (cleanURL === '') return formik.setFieldError('username', 'Username can\'t be nothing');
const data = {
url: cleanURL,
vanity: cleanVanity === '' ? null : cleanVanity,
};
setCreateOpen(false);
setLoading(true);
const res = await fetch('/api/shorten', {
method: 'POST',
headers: {
'Authorization': user.token,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
const json = await res.json();
if (json.error) {
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);
},
}); });
const onSubmit = async (values) => {
const cleanURL = values.url.trim();
const cleanVanity = values.vanity.trim();
if (cleanURL === '') return form.setFieldError('url', 'URL can\'t be nothing');
const data = {
url: cleanURL,
vanity: cleanVanity === '' ? null : cleanVanity,
};
setCreateOpen(false);
const res = await fetch('/api/shorten', {
method: 'POST',
headers: {
'Authorization': user.token,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
const json = await res.json();
if (json.error) {
notif.showNotification({
title: 'Failed to create URL',
message: json.error,
color: 'red',
icon: <Cross1Icon />,
});
} else {
notif.showNotification({
title: 'URL shortened',
message: json.url,
color: 'green',
icon: <Link1Icon />,
});
}
updateURLs();
};
useEffect(() => { useEffect(() => {
updateURLs(); updateURLs();
}, []); }, []);
return ( return (
<> <>
<Backdrop open={loading}/> <Modal
<Alert open={open} setOpen={setOpen} message={message} severity={severity} /> opened={createOpen}
onClose={() => setCreateOpen(false)}
title={<Title>Shorten URL</Title>}
>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput id='url' label='URL' {...form.getInputProps('url')} />
<TextInput id='vanity' label='Vanity' {...form.getInputProps('vanity')} />
<Dialog open={createOpen} onClose={() => setCreateOpen(false)}> <Group position='right' mt={22}>
<DialogTitle>Shorten URL</DialogTitle> <Button onClick={() => setCreateOpen(false)}>Cancel</Button>
<form onSubmit={formik.handleSubmit}> <Button type='submit'>Submit</Button>
<DialogContent> </Group>
<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> </form>
</Dialog> </Modal>
{!urls.length ? ( <Group>
<Box <Title sx={{ marginBottom: 12 }}>URLs</Title>
display='flex' <ActionIcon variant='filled' color='primary' onClick={() => setCreateOpen(true)}><PlusIcon/></ActionIcon>
justifyContent='center' </Group>
alignItems='center'
pt={2}
pb={3}
>
<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>}
<Grid container spacing={2}> <SimpleGrid
cols={4}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{urls.length ? urls.map(url => ( {urls.length ? urls.map(url => (
<Grid item xs={12} sm={3} key={url.id}> <Card key={url.id} sx={{ maxWidth: '100%' }} shadow='sm'>
<Card sx={{ maxWidth: '100%' }}> <Group position='apart'>
<CardHeader <Group position='left'>
action={ <Title>{url.vanity ?? url.id}</Title>
<> </Group>
<IconButton aria-label='copy' onClick={() => copyURL(url)}> <Group position='right'>
<CopyIcon /> <ActionIcon href={url.url} component='a' target='_blank'><Link1Icon/></ActionIcon>
</IconButton> <ActionIcon aria-label='copy' onClick={() => copyURL(url)}>
<IconButton aria-label='delete' onClick={() => deleteURL(url)}> <CopyIcon />
<DeleteIcon /> </ActionIcon>
</IconButton> <ActionIcon aria-label='delete' onClick={() => deleteURL(url)}>
</> <TrashIcon />
} </ActionIcon>
title={url.vanity ?? url.id} </Group>
subheader={<Link href={url.destination}>{url.destination}</Link>} </Group>
/> </Card>
</Card> )) : [1,2,3,4,5,6,7].map(x => (
</Grid> <div key={x}>
)) : null} <Skeleton width='100%' height={60} sx={{ borderRadius: 1 }}/>
</Grid> </div>
))}
</SimpleGrid>
</> </>
); );
} }

View file

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

View file

@ -16,7 +16,7 @@ export default function login() {
setLoading(true); setLoading(true);
const res = await useFetch('/api/user'); 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)); dispatch(updateUser(res));
setUser(res); setUser(res);

View file

@ -1,6 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import type { CookieSerializeOptions } from 'cookie'; import type { CookieSerializeOptions } from 'cookie';
import type { Image, Theme, User } from '@prisma/client';
import { serialize } from 'cookie'; import { serialize } from 'cookie';
import { sign64, unsign64 } from '../util'; import { sign64, unsign64 } from '../util';
@ -23,7 +22,6 @@ export type NextApiReq = NextApiRequest & {
embedTitle: string; embedTitle: string;
embedColor: string; embedColor: string;
systemTheme: string; systemTheme: string;
customTheme?: Theme;
administrator: boolean; administrator: boolean;
id: number; id: number;
password: string; password: string;
@ -111,7 +109,6 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
id: true, id: true,
password: true, password: true,
systemTheme: true, systemTheme: true,
customTheme: true,
token: true, token: true,
username: true, username: true,
}, },

View file

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

View file

@ -3,15 +3,36 @@
import createTheme from '.'; import createTheme from '.';
export default createTheme({ export default createTheme({
type: 'dark', colorScheme: 'dark',
primary: '#E6B450', primaryColor: 'orange',
secondary: '#FFEE99', other: {
error: '#F07178', AppShell_backgroundColor: '#0a0e14',
warning: '#F29668', hover: '#191e29',
info: '#95E6CB', },
border: '#191e29', colors: {
background: { dark: [
main: '#0A0E14', '#ffffff',
paper: '#0D1016', '#47494E',
'#6c707a',
'#33353B',
'#303238',
'#2C2E34',
'#25272D',
'#0d1016',
'#11141A',
'#0D1016',
],
orange: [
'#FFFFFF',
'#FCF6EA',
'#F9EDD4',
'#F3DAA8',
'#F2D69D',
'#F0D192',
'#EFCC87',
'#EDC77C',
'#EABE66',
'#E6B450',
],
}, },
}); });

View file

@ -3,15 +3,24 @@
import createTheme from '.'; import createTheme from '.';
export default createTheme({ export default createTheme({
type: 'light', colorScheme: 'light',
primary: '#FF9940', primaryColor: 'orange',
secondary: '#E6BA7E', other: {
error: '#F07171', AppShell_backgroundColor: '#FAFAFA',
warning: '#ED9366', hover: '#FAFAFA',
info: '#95E6CB', },
border: '#e3e3e3', colors: {
background: { orange: [
main: '#FAFAFA', '#FFFFFF',
paper: '#FFFFFF', '#FCF6EA',
'#F9EDD4',
'#F3DAA8',
'#F2D69D',
'#F0D192',
'#EFCC87',
'#EDC77C',
'#EABE66',
'#E6B450',
],
}, },
}); });

View file

@ -3,15 +3,36 @@
import createTheme from '.'; import createTheme from '.';
export default createTheme({ export default createTheme({
type: 'dark', colorScheme: 'dark',
primary: '#FFCC66', primaryColor: 'orange',
secondary: '#FFD580', other: {
error: '#F28779', AppShell_backgroundColor: '#1F2430',
warning: '#F29E74', hover: '#2a2f3b',
info: '#95E6CB', },
border: '#363c4d', colors: {
background: { dark: [
main: '#1F2430', '#ffffff',
paper: '#232834', '#91949A',
'#6c707a',
'#3F434E',
'#313641',
'#2A2F3B',
'#2e333e',
'#232834',
'#11141A',
'#0D1016',
],
orange: [
'#FFFFFF',
'#FCF6EA',
'#F9EDD4',
'#F3DAA8',
'#F2D69D',
'#F0D192',
'#EFCC87',
'#EDC77C',
'#EABE66',
'#E6B450',
],
}, },
}); });

View file

@ -1,15 +1,36 @@
import createTheme from '.'; import createTheme from '.';
export default createTheme({ export default createTheme({
type: 'dark', colorScheme: 'dark',
primary: '#2c39a6', primaryColor: 'blue',
secondary: '#7344e2', other: {
error: '#ff4141', AppShell_backgroundColor: '#000000',
warning: '#ff9800', hover: '#2b2b2b',
info: '#2f6fb9', },
border: '#2b2b2b', colors: {
background: { dark: [
main: '#000000', '#ffffff',
paper: '#060606', '#A7A9AD',
'#7B7E84',
'#61646A',
'#54575D',
'#46494F',
'#3C3F44',
'#060606',
'#141517',
'#000000',
],
blue: [
'#FFFFFF',
'#7C7DC2',
'#7778C0',
'#6C6FBC',
'#575DB5',
'#4D54B2',
'#424BAE',
'#3742AA',
'#323EA8',
'#2C39A6',
],
}, },
}); });

View file

@ -1,15 +1,36 @@
import createTheme from '.'; import createTheme from '.';
export default createTheme({ export default createTheme({
type: 'dark', colorScheme: 'dark',
primary: '#2c39a6', primaryColor: 'blue',
secondary: '#7344e2', other: {
error: '#ff4141', AppShell_backgroundColor: '#05070f',
warning: '#ff9800', hover: '#181c28',
info: '#2f6fb9', },
border: '#1b2541', colors: {
background: { dark: [
main: '#05070f', '#FFFFFF',
paper: '#0c101c', '#293747',
'#6C7A8D',
'#232F41',
'#41566e',
'#171F35',
'#181c28',
'#0c101c',
'#060824',
'#00001E',
],
blue: [
'#FFFFFF',
'#7C7DC2',
'#7778C0',
'#6C6FBC',
'#575DB5',
'#4D54B2',
'#424BAE',
'#3742AA',
'#323EA8',
'#2C39A6',
],
}, },
}); });

View file

@ -3,15 +3,36 @@
import createTheme from '.'; import createTheme from '.';
export default createTheme({ export default createTheme({
type: 'dark', colorScheme: 'dark',
primary: '#BD93F9', primaryColor: 'violet',
secondary: '#6272A4', other: {
error: '#FF5555', AppShell_backgroundColor: '#282A36',
warning: '#FFB86C', hover: '#4e5062',
info: '#8BE9FD',
border: '#7D8096',
background: {
main: '#282A36',
paper: '#44475A',
}, },
}); colors: {
dark: [
'#FFFFFF',
'#CED0D4',
'#E8E8EB',
'#D1D1D6',
'#BABAC2',
'#A2A3AD',
'#4e5062',
'#44475A',
'#5C5E6F',
'#44475A',
],
violet: [
'#FFFFFF',
'#F7F2FF',
'#EFE4FE',
'#EBDEFE',
'#E7D7FD',
'#DEC9FC',
'#D6BCFC',
'#CEAEFB',
'#C6A1FA',
'#BD93F9',
],
},
});

View file

@ -1,54 +1,5 @@
import { createTheme as muiCreateTheme } from '@mui/material/styles'; import { MantineThemeOverride } from '@mantine/core';
export interface ThemeOptions { export default function createTheme(o: MantineThemeOverride) {
type: 'dark' | 'light'; return o;
primary: string;
secondary: string;
error: string;
warning: string;
info: string;
border: string;
background: ThemeOptionsBackground;
}
export interface ThemeOptionsBackground {
main: string;
paper: string;
}
export default function createTheme(o: ThemeOptions) {
return muiCreateTheme({
palette: {
mode: o.type,
primary: {
main: o.primary,
},
secondary: {
main: o.secondary,
},
background: {
default: o.background.main,
paper: o.background.paper,
},
error: {
main: o.error,
},
warning: {
main: o.warning,
},
info: {
main: o.info,
},
divider: o.border,
},
components: {
MuiTableHead: {
styleOverrides: {
root: {
backgroundColor: o.border,
},
},
},
},
});
} }

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

View file

@ -1,15 +1,36 @@
import createTheme from '.'; import createTheme from '.';
export default createTheme({ export default createTheme({
type: 'dark', colorScheme: 'dark',
primary: '#3498db', primaryColor: 'blue',
secondary: '#7344e2', other: {
error: '#db5b5b', AppShell_backgroundColor: '#1b1d24',
warning: '#ff9800', hover: '#3c3f44',
info: '#2f6fb9', },
border: '#14161b', colors: {
background: { dark: [
main: '#1b1d24', '#FFFFFF',
paper: '#202329', '#C8C8CA',
'#F5F5F5',
'#909194',
'#585A5F',
'#4A4D52',
'#3C3F44',
'#202329',
'#272A30',
'#202329',
],
blue: [
'#FFFFFF',
'#E6F3FB',
'#CDE6F6',
'#B4D9F2',
'#9ACCED',
'#8EC6EB',
'#81BFE9',
'#67B2E4',
'#4EA5E0',
'#3498DB',
],
}, },
}); });

View file

@ -3,15 +3,36 @@
import createTheme from '.'; import createTheme from '.';
export default createTheme({ export default createTheme({
type: 'dark', colorScheme: 'dark',
primary: '#81A1C1', primaryColor: 'blue',
secondary: '#88C0D0', other: {
error: '#BF616A', AppShell_backgroundColor: '#2E3440',
warning: '#EBCB8B', hover: '#6c727e',
info: '#5E81AC', },
border: '#565e70', colors: {
background: { dark: [
main: '#2E3440', '#FFFFFF',
paper: '#3B4252', '#CED0D4',
'#B6B9BF',
'#9DA1A9',
'#858A94',
'#6C727E',
'#606673',
'#3B4252',
'#484E5D',
'#3B4252',
],
blue: [
'#FFFFFF',
'#E0E8F0',
'#C0D0E0',
'#B9CBDD',
'#B1C5D9',
'#A1B9D1',
'#99B3CD',
'#91ADC9',
'#89A7C5',
'#81A1C1',
],
}, },
}); });

View file

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

View file

@ -1,15 +1,37 @@
import createTheme from '.'; import createTheme from '.';
export default createTheme({ export default createTheme({
type: 'dark', colorScheme: 'dark',
primary: '#5294e2', primaryColor: 'blue',
secondary: '#7344e2', other: {
error: '##f04a50', AppShell_backgroundColor: '#32343d',
warning: '#ff9800', hover: '#34363d',
info: '#2f6fb9', },
border: '#4a4c54', colors: {
background: { dark: [
main: '#32343d', '#FFFFFF',
paper: '#262830', '#C9CACC',
'#F5F5F5',
'#78797E',
'#5D5E64',
'#42434A',
'#34363D',
'#262830',
'#2A2C34',
'#262830',
],
blue: [
'#FFFFFF',
'#E6F3FB',
'#CDE6F6',
'#B4D9F2',
'#9ACCED',
'#8EC6EB',
'#81BFE9',
'#67B2E4',
'#4EA5E0',
'#3498DB',
],
}, },
}); });

View file

@ -1,16 +1,18 @@
import React from 'react'; import React from 'react';
import { Box, Typography } from '@mui/material'; import { Box, Text } from '@mantine/core';
export default function FourOhFour() { export default function FourOhFour() {
return ( return (
<> <>
<Box <Box
display='flex' sx={{
justifyContent='center' display: 'flex',
alignItems='center' alignItems: 'center',
minHeight='100vh' minHeight: '100vh',
justifyContent: 'center',
}}
> >
<Typography variant='h2'>404 - Not Found</Typography> <Text size='xl'>404 - Not Found</Text>
</Box> </Box>
</> </>
); );

View file

@ -1,13 +1,16 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import { GetServerSideProps } from 'next'; import { GetServerSideProps } from 'next';
import { Box } from '@mui/material'; import { Box, useMantineTheme } from '@mantine/core';
import config from 'lib/config'; import config from 'lib/config';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
import { getFile } from '../../server/util'; import { getFile } from '../../server/util';
import { parse } from 'lib/clientUtils'; 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}`; const dataURL = (route: string) => `${route}/${image.file}`;
// reapply date from workaround // reapply date from workaround
@ -40,10 +43,12 @@ export default function EmbeddedImage({ image, user, normal }) {
<title>{image.file}</title> <title>{image.file}</title>
</Head> </Head>
<Box <Box
display='flex' sx={{
justifyContent='center' display: 'flex',
alignItems='center' alignItems: 'center',
minHeight='100vh' minHeight: '100vh',
justifyContent: 'center',
}}
> >
<img src={dataURL('/r')} alt={dataURL('/r')} id='image_content' /> <img src={dataURL('/r')} alt={dataURL('/r')} id='image_content' />
</Box> </Box>
@ -113,20 +118,29 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
//@ts-ignore workaround because next wont allow date //@ts-ignore workaround because next wont allow date
image.created_at = image.created_at.toString(); image.created_at = image.created_at.toString();
const prismRender = Object.keys(exts).includes(image.file.split('.').pop());
// let prismRenderCode;/
// if (prismRender) prismRenderCode = (await getFile(config.uploader.directory, id)).toString();
if (prismRender) return {
redirect: {
destination: `/code/${image.file}`,
permanent: true,
},
};
if (!image.mimetype.startsWith('image')) { if (!image.mimetype.startsWith('image')) {
const data = await getFile(config.uploader.directory, id); const data = await getFile(config.uploader.directory, id);
if (!data) return { notFound: true }; if (!data) return { notFound: true };
context.res.end(data); context.res.end(data);
return { props: {} }; return { props: {} };
}; }
return { return {
props: { props: {
image, image,
user, user,
normal: config.uploader.route,
}, },
}; };
} }

View file

@ -1,33 +1,19 @@
import React from 'react'; import React from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import PropTypes from 'prop-types';
import Head from 'next/head'; import Head from 'next/head';
import Theming from 'components/Theming';
import { useStore } from 'lib/redux/store'; import { useStore } from 'lib/redux/store';
import ZiplineTheming from 'components/Theming';
export default function MyApp({ Component, pageProps }) { export default function MyApp({ Component, pageProps }) {
const store = useStore(); const store = useStore();
React.useEffect(() => {
const jssStyles = document.querySelector('#jss-server-side');
if (jssStyles) jssStyles.parentElement.removeChild(jssStyles);
}, []);
return ( return (
<Provider store={store}> <Provider store={store}>
<Head> <Head>
<title>{Component.title}</title> <title>{Component.title}</title>
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width' /> <meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width' />
</Head> </Head>
<Theming <ZiplineTheming Component={Component} pageProps={pageProps} />
Component={Component}
pageProps={pageProps}
/>
</Provider> </Provider>
); );
} }
MyApp.propTypes = {
Component: PropTypes.elementType.isRequired,
pageProps: PropTypes.object.isRequired,
};

View file

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import Document, { Html, Head, Main, NextScript } from 'next/document'; import Document, { Html, Head, Main, NextScript } from 'next/document';
import { createGetInitialProps } from '@mantine/next';
const getInitialProps = createGetInitialProps();
class MyDocument extends Document { class MyDocument extends Document {
static async getInitialProps(ctx) { static getInitialProps = getInitialProps;
const initialProps = await Document.getInitialProps(ctx);
return { ...initialProps };
}
render() { render() {
return ( return (

View file

@ -32,8 +32,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
const valid = await checkPassword(password, user.password); const valid = await checkPassword(password, user.password);
if (!valid) return res.forbid('Wrong password'); if (!valid) return res.forbid('Wrong password');
// 604800 seconds is 1 week res.setCookie('user', user.id, { sameSite: true, expires: new Date(Date.now() + (6.048e+8 * 2)), path: '/' });
res.setCookie('user', user.id, { sameSite: true, maxAge: 604800, path: '/' });
Logger.get('user').info(`User ${user.username} (${user.id}) logged in`); Logger.get('user').info(`User ${user.username} (${user.id}) logged in`);

View file

@ -51,24 +51,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
data: { systemTheme: req.body.systemTheme }, data: { systemTheme: req.body.systemTheme },
}); });
if (req.body.customTheme) {
if (user.customTheme) await prisma.user.update({
where: { id: user.id },
data: {
customTheme: {
update: {
...req.body.customTheme,
},
},
},
}); else await prisma.theme.create({
data: {
userId: user.id,
...req.body.customTheme,
},
});
}
const newUser = await prisma.user.findFirst({ const newUser = await prisma.user.findFirst({
where: { where: {
id: Number(user.id), id: Number(user.id),
@ -82,7 +64,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
images: false, images: false,
password: false, password: false,
systemTheme: true, systemTheme: true,
customTheme: true,
token: true, token: true,
username: true, username: true,
}, },

View file

@ -21,6 +21,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
created_at: true, created_at: true,
file: true, file: true,
mimetype: true, mimetype: true,
id: true,
}, },
}); });

View file

@ -33,7 +33,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
token: true, token: true,
embedColor: true, embedColor: true,
embedTitle: true, embedTitle: true,
customTheme: true,
systemTheme: true, systemTheme: true,
}, },
}); });

17
src/pages/api/version.ts Normal file
View 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);

View file

@ -1,82 +1,96 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect } from 'react';
import { Typography, Box, TextField, Stack, Button, styled } from '@mui/material';
import { useRouter } from 'next/router'; 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 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() { 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 router = useRouter();
const notif = useNotifications();
const [versions, setVersions] = React.useState<{ upstream: string, local: string }>(null);
const formik = useFormik({ const form = useForm({
initialValues: { initialValues: {
username: '', username: '',
password: '', password: '',
}, },
onSubmit: async values => {
const username = values.username.trim();
const password = values.password.trim();
if (username === '') return formik.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');
}
},
}); });
const onSubmit = async values => {
const username = values.username.trim();
const password = values.password.trim();
if (username === '') return form.setFieldError('username', 'Username can\'t be nothing');
const res = await useFetch('/api/auth/login', 'POST', {
username, password,
});
if (res.error) {
notif.showNotification({
title: 'Login Failed',
message: res.error,
color: 'red',
icon: <Cross1Icon />,
});
} else {
router.push(router.query.url as string || '/dashboard');
}
};
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const a = await fetch('/api/user'); const a = await fetch('/api/user');
if (a.ok) router.push('/dashboard'); if (a.ok) router.push('/dashboard');
else {
const v = await useFetch('/api/version');
setVersions(v);
if (v.local !== v.upstream) {
notif.showNotification({
title: 'Update available',
message: `A new version of Zipline is available. You are running ${v.local} and the latest version is ${v.upstream}.`,
icon: <DownloadIcon />,
});
}
}
})(); })();
}, []); }, []);
return ( return (
<> <>
<Alert open={open} setOpen={setOpen} severity={severity} message={message} /> <Center sx={{ height: '100vh' }}>
<Backdrop open={loadingOpen} /> <div>
<Box <Title align='center'>Zipline</Title>
display='flex' <form onSubmit={form.onSubmit((v) => onSubmit(v))}>
height='screen' <TextInput size='lg' id='username' label='Username' {...form.getInputProps('username')} />
alignItems='center' <TextInput size='lg' id='password' label='Password' type='password' {...form.getInputProps('password')} />
justifyContent='center'
sx={{ height: '24rem' }}
>
<Stack>
<Typography variant='h3' textAlign='center'>
Zipline
</Typography>
<form onSubmit={formik.handleSubmit}> <Button size='lg' type='submit' fullWidth mt={12}>Login</Button>
<TextInput formik={formik} id='username' label='Username' />
<TextInput formik={formik} id='password' label='Password' type='password' />
<Box my={2}>
<Button variant='contained' fullWidth type='submit'>
Login
</Button>
</Box>
</form> </form>
</Stack> </div>
</Center>
<Box
sx={{
zIndex: 99,
position: 'fixed',
bottom: '10px',
right: '20px',
}}
>
{versions && (
<Tooltip
wrapLines
width={220}
transition='rotate-left'
transitionDuration={200}
label={versions.local !== versions.upstream ? 'Looks like you are running an outdated version of Zipline. Please update to the latest version.' : 'You are running the latest version of Zipline.'}
>
<Badge radius='md' size='lg' variant='dot' color={versions.local !== versions.upstream ? 'red' : 'primary'}>{versions.local}</Badge>
</Tooltip>
)}
</Box> </Box>
</> </>
); );

View file

@ -1,9 +1,11 @@
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { Backdrop, CircularProgress } from '@mui/material'; import { LoadingOverlay } from '@mantine/core';
export default function Logout() { export default function Logout() {
const router = useRouter(); const router = useRouter();
const [visible, setVisible] = useState(true);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@ -18,12 +20,7 @@ export default function Logout() {
}, []); }, []);
return ( return (
<Backdrop <LoadingOverlay visible={visible} />
sx={{ color: '#fff', zIndex: t => t.zIndex.drawer + 1 }}
open
>
<CircularProgress color='inherit' />
</Backdrop>
); );
} }

23
src/pages/code/[id].tsx Normal file
View 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;
}

View file

@ -11,8 +11,6 @@ export default function FilesPage() {
return ( return (
<Layout <Layout
user={user} user={user}
loading={loading}
noPaper={false}
> >
<Files /> <Files />
</Layout> </Layout>

View file

@ -5,14 +5,11 @@ import Dashboard from 'components/pages/Dashboard';
export default function DashboardPage() { export default function DashboardPage() {
const { user, loading } = useLogin(); const { user, loading } = useLogin();
if (loading) return null; if (loading) return null;
return ( return (
<Layout <Layout
user={user} user={user}
loading={loading}
noPaper={false}
> >
<Dashboard /> <Dashboard />
</Layout> </Layout>

View file

@ -11,8 +11,6 @@ export default function ManagePage() {
return ( return (
<Layout <Layout
user={user} user={user}
loading={loading}
noPaper={false}
> >
<Manage /> <Manage />
</Layout> </Layout>

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

View file

@ -13,8 +13,6 @@ export default function UploadPage({ route }) {
return ( return (
<Layout <Layout
user={user} user={user}
loading={loading}
noPaper={false}
> >
<Upload route={route}/> <Upload route={route}/>
</Layout> </Layout>

View file

@ -11,8 +11,6 @@ export default function UrlsPage() {
return ( return (
<Layout <Layout
user={user} user={user}
loading={loading}
noPaper={false}
> >
<Urls /> <Urls />
</Layout> </Layout>

View file

@ -11,8 +11,6 @@ export default function UsersPage() {
return ( return (
<Layout <Layout
user={user} user={user}
loading={loading}
noPaper={false}
> >
<Users /> <Users />
</Layout> </Layout>

View file

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

2249
yarn.lock

File diff suppressed because it is too large Load diff