mirror of
https://github.com/diced/zipline.git
synced 2025-04-04 23:21:17 -05:00
feat: overhaul a lot of stuff (#171)
* feat: ssr for /code/[id], fix: syntax highlighting * feat: cache responses * ref: eslint * wip * Create .gitattributes * wip again * redesign dashboard * ref: use react-query for url ref: break into components feat: loading animation for delete feat: no url image * feat: use react-query mutation for files * ref: update sizing on code input * chore(deps): update mantine * feat: overhaul stats page * fix: incorrectly calculating stat % change * fix: use latest data in stats per day * feat: add validation on stats amount query string * refactor: clean up imports & code * fix: remove prettier (fixes eslint) * ref: run eslint autofix * ref: more eslint * ref: replace undraw on homepage with react-feather * refactor: remove tailwind & add responsiveness to stuff * fix: colors on file placeholder * fix: make actions work * feat: new sharex configuration generator Co-authored-by: diced <pranaco2@gmail.com>
This commit is contained in:
parent
722372c7f6
commit
dc926e9f5a
45 changed files with 4661 additions and 653 deletions
14
.gitattributes
vendored
Normal file
14
.gitattributes
vendored
Normal file
|
@ -0,0 +1,14 @@
|
|||
# Set the default behavior, in case people don't have core.autocrlf set.
|
||||
* text=auto
|
||||
|
||||
# Explicitly declare text files you want to always be normalized and converted
|
||||
# to native line endings on checkout.
|
||||
*.c text
|
||||
*.h text
|
||||
|
||||
# Declare files that will always have CRLF line endings on checkout.
|
||||
*.sln text eol=crlf
|
||||
|
||||
# Denote all files that are truly binary and should not be modified.
|
||||
*.png binary
|
||||
*.jpg binary
|
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
@ -0,0 +1 @@
|
|||
16.16.0
|
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"editor.tabSize": 2,
|
||||
"files.eol": "\n"
|
||||
}
|
|
@ -11,6 +11,20 @@ module.exports = {
|
|||
},
|
||||
];
|
||||
},
|
||||
webpack(config) {
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
use: ['@svgr/webpack'],
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
images: {
|
||||
domains: [
|
||||
// For sharex icon in manage user
|
||||
'getsharex.com',
|
||||
],
|
||||
},
|
||||
poweredByHeader: false,
|
||||
reactStrictMode: true,
|
||||
};
|
37
package.json
37
package.json
|
@ -3,20 +3,16 @@
|
|||
"version": "3.5.1",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "REACT_EDITOR=code NODE_ENV=development tsx src/server",
|
||||
|
||||
"dev": "cross-env REACT_EDITOR=code NODE_ENV=development tsx src/server",
|
||||
"build": "npm-run-all build:schema build:next",
|
||||
"build:next": "next build",
|
||||
"build:schema": "prisma generate --schema=prisma/schema.prisma",
|
||||
|
||||
"migrate:dev": "prisma migrate dev --create-only",
|
||||
"start": "tsx src/server",
|
||||
"lint": "next lint",
|
||||
|
||||
"docker:run": "docker-compose up -d",
|
||||
"docker:down": "docker-compose down",
|
||||
"docker:build-dev": "docker-compose --file docker-compose.dev.yml up --build",
|
||||
|
||||
"scripts:read-config": "tsx src/scripts/read-config"
|
||||
},
|
||||
"dependencies": {
|
||||
|
@ -24,20 +20,26 @@
|
|||
"@emotion/react": "^11.9.3",
|
||||
"@emotion/server": "^11.4.0",
|
||||
"@iarna/toml": "2.2.5",
|
||||
"@mantine/core": "^5.0.0",
|
||||
"@mantine/dropzone": "^5.0.0",
|
||||
"@mantine/form": "^5.0.0",
|
||||
"@mantine/hooks": "^5.0.0",
|
||||
"@mantine/modals": "^5.0.0",
|
||||
"@mantine/next": "^5.0.0",
|
||||
"@mantine/notifications": "^5.0.0",
|
||||
"@mantine/nprogress": "^5.0.0",
|
||||
"@mantine/prism": "^5.0.0",
|
||||
"@mantine/core": "^5.2.6",
|
||||
"@mantine/dropzone": "^5.2.6",
|
||||
"@mantine/form": "^5.2.6",
|
||||
"@mantine/hooks": "^5.2.6",
|
||||
"@mantine/modals": "^5.2.6",
|
||||
"@mantine/next": "^5.2.6",
|
||||
"@mantine/notifications": "^5.2.6",
|
||||
"@mantine/nprogress": "^5.2.6",
|
||||
"@mantine/prism": "^5.2.6",
|
||||
"@prisma/client": "^4.1.0",
|
||||
"@prisma/internals": "^4.1.0",
|
||||
"@prisma/migrate": "^4.1.0",
|
||||
"@reduxjs/toolkit": "^1.8.2",
|
||||
"@svgr/webpack": "^6.3.1",
|
||||
"@tabler/icons": "^1.92.0",
|
||||
"@tanstack/react-query": "^4.2.3",
|
||||
"argon2": "^0.28.5",
|
||||
"chart.js": "^3.9.1",
|
||||
"chartjs-plugin-datalabels": "^2.1.0",
|
||||
"color-hash": "^2.0.1",
|
||||
"colorette": "^2.0.19",
|
||||
"cookie": "^0.5.0",
|
||||
"dayjs": "^1.11.5",
|
||||
|
@ -50,8 +52,10 @@
|
|||
"ms": "canary",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"next": "^12.1.6",
|
||||
"npm": "^8.19.1",
|
||||
"prisma": "^4.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^4.3.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-feather": "^2.0.10",
|
||||
"react-redux": "^8.0.2",
|
||||
|
@ -65,11 +69,14 @@
|
|||
"@types/minio": "^7.0.13",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^15.12.2",
|
||||
"@types/react": "^18.0.18",
|
||||
"@types/sharp": "^0.30.5",
|
||||
"babel-plugin-import": "^1.13.5",
|
||||
"cross-env": "^7.0.3",
|
||||
"esbuild": "^0.14.44",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-next": "12.1.6",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"ts-node": "^10.8.1",
|
||||
"tsx": "^3.8.0",
|
||||
|
@ -80,4 +87,4 @@
|
|||
"url": "https://github.com/diced/zipline.git"
|
||||
},
|
||||
"packageManager": "yarn@3.2.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { Card as MCard, Title } from '@mantine/core';
|
||||
|
||||
export default function Card({ name, children, ...other }) {
|
||||
|
||||
export default function Card({ name, children, ...other }) {
|
||||
return (
|
||||
<MCard p='md' shadow='sm' {...other}>
|
||||
<Title order={2}>{name}</Title>
|
||||
{name && <Title order={2}>{name}</Title>}
|
||||
{children}
|
||||
</MCard>
|
||||
);
|
||||
|
|
|
@ -4,7 +4,7 @@ const useStyles = createStyles((theme, { size }: { size: MantineSize }) => ({
|
|||
input: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: theme.fn.size({ size, sizes: theme.fontSizes }) - 2,
|
||||
height: '100vh',
|
||||
height: '80vh',
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { Button, Card, Group, Modal, Stack, Text, Title, Tooltip, useMantineTheme } from '@mantine/core';
|
||||
import { Button, Card, Group, LoadingOverlay, Modal, Stack, Text, Title, Tooltip, useMantineTheme } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { relativeTime } from 'lib/clientUtils';
|
||||
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
|
||||
import { useState } from 'react';
|
||||
import Type from './Type';
|
||||
import { CalendarIcon, ClockIcon, CopyIcon, CrossIcon, DeleteIcon, FileIcon, HashIcon, ImageIcon, StarIcon } from './icons';
|
||||
import MutedText from './MutedText';
|
||||
import { relativeTime } from 'lib/clientUtils';
|
||||
import Type from './Type';
|
||||
|
||||
export function FileMeta({ Icon, title, subtitle, ...other }) {
|
||||
return other.tooltip ? (
|
||||
|
@ -32,28 +32,37 @@ export function FileMeta({ Icon, title, subtitle, ...other }) {
|
|||
|
||||
export default function File({ image, updateImages }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const deleteFile = useFileDelete();
|
||||
const favoriteFile = useFileFavorite();
|
||||
const clipboard = useClipboard();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const loading = deleteFile.isLoading || favoriteFile.isLoading;
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
|
||||
if (!res.error) {
|
||||
updateImages(true);
|
||||
showNotification({
|
||||
title: 'File Deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <DeleteIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to delete file',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
deleteFile.mutate(image.id, {
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'File Deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <DeleteIcon />,
|
||||
});
|
||||
},
|
||||
|
||||
setOpen(false);
|
||||
onError: (res: any) => {
|
||||
showNotification({
|
||||
title: 'Failed to delete file',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
|
@ -67,16 +76,26 @@ export default function File({ image, updateImages }) {
|
|||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
const data = await useFetch('/api/user/files', 'PATCH', { id: image.id, favorite: !image.favorite });
|
||||
if (!data.error) updateImages(true);
|
||||
showNotification({
|
||||
title: 'Image is now ' + (!image.favorite ? 'favorited' : 'unfavorited'),
|
||||
message: '',
|
||||
icon: <StarIcon />,
|
||||
favoriteFile.mutate({ id: image.id, favorite: !image.favorite }, {
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'Image is now ' + (!image.favorite ? 'favorited' : 'unfavorited'),
|
||||
message: '',
|
||||
icon: <StarIcon />,
|
||||
});
|
||||
},
|
||||
|
||||
onError: (res: any) => {
|
||||
showNotification({
|
||||
title: 'Failed to favorite file',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
|
@ -85,6 +104,7 @@ export default function File({ image, updateImages }) {
|
|||
title={<Title>{image.file}</Title>}
|
||||
size='xl'
|
||||
>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Stack>
|
||||
<Type
|
||||
file={image}
|
||||
|
@ -116,6 +136,7 @@ export default function File({ image, updateImages }) {
|
|||
</Modal>
|
||||
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
|
||||
<Card.Section>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Type
|
||||
file={image}
|
||||
sx={{ minHeight: 200, maxHeight: 320, fontSize: 70, width: '100%', cursor: 'pointer' }}
|
||||
|
|
|
@ -3,6 +3,7 @@ import { useClipboard } from '@mantine/hooks';
|
|||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useVersion } from 'lib/queries/version';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
import { useStoreDispatch } from 'lib/redux/store';
|
||||
import Link from 'next/link';
|
||||
|
@ -114,7 +115,8 @@ export default function Layout({ children, user, props }) {
|
|||
|
||||
const [token, setToken] = useState(user?.token);
|
||||
const [systemTheme, setSystemTheme] = useState(user.systemTheme ?? 'system');
|
||||
const [version, setVersion] = useState<{ local: string, upstream: string }>(null);
|
||||
// const [version, setVersion] = useState<{ local: string, upstream: string }>(null);
|
||||
const version = useVersion();
|
||||
const [opened, setOpened] = useState(false); // navigation open
|
||||
const [open, setOpen] = useState(false); // manage acc dropdown
|
||||
|
||||
|
@ -195,15 +197,6 @@ export default function Layout({ children, user, props }) {
|
|||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const data = await useFetch('/api/version');
|
||||
if (!data.error) {
|
||||
setVersion(data);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
navbarOffsetBreakpoint='sm'
|
||||
|
@ -264,12 +257,12 @@ export default function Layout({ children, user, props }) {
|
|||
</Link>
|
||||
)) : null}
|
||||
</Navbar.Section>
|
||||
{version ? (
|
||||
{version.isSuccess ? (
|
||||
<Navbar.Section>
|
||||
<Tooltip
|
||||
label={
|
||||
version.local !== version.upstream
|
||||
? `You are running an outdated version of Zipline, refer to the docs on how to update to ${version.upstream}`
|
||||
version.data.local !== version.data.upstream
|
||||
? `You are running an outdated version of Zipline, refer to the docs on how to update to ${version.data.upstream}`
|
||||
: 'You are running the latest version of Zipline'
|
||||
}
|
||||
>
|
||||
|
@ -278,9 +271,9 @@ export default function Layout({ children, user, props }) {
|
|||
radius='md'
|
||||
size='lg'
|
||||
variant='dot'
|
||||
color={version.local !== version.upstream ? 'red' : 'primary'}
|
||||
color={version.data.local !== version.data.upstream ? 'red' : 'primary'}
|
||||
>
|
||||
{version.local}
|
||||
{version.data.local}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Navbar.Section>
|
||||
|
@ -314,6 +307,7 @@ export default function Layout({ children, user, props }) {
|
|||
'&:hover': {
|
||||
backgroundColor: t.other.hover,
|
||||
},
|
||||
color: t.colorScheme === 'dark' ? 'white' : 'black',
|
||||
})}
|
||||
size='xl'
|
||||
p='sm'
|
||||
|
|
84
src/components/StatCard.tsx
Normal file
84
src/components/StatCard.tsx
Normal file
|
@ -0,0 +1,84 @@
|
|||
import { Card, createStyles, Group, Text } from '@mantine/core';
|
||||
import { ArrowDownRight, ArrowUpRight } from 'react-feather';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
root: {
|
||||
padding: theme.spacing.xl * 1.5,
|
||||
},
|
||||
|
||||
value: {
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
},
|
||||
|
||||
diff: {
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
icon: {
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[3] : theme.colors.gray[4],
|
||||
},
|
||||
|
||||
title: {
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
}));
|
||||
|
||||
interface StatsGridProps {
|
||||
stat: {
|
||||
title: string;
|
||||
icon: React.ReactNode,
|
||||
value: string;
|
||||
desc: string;
|
||||
diff?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function StatCard({ stat }: StatsGridProps) {
|
||||
const { classes } = useStyles();
|
||||
if (stat.diff) stat.diff = Math.round(stat.diff);
|
||||
|
||||
return (
|
||||
<Card p='md' radius='md' key={stat.title}>
|
||||
<Group position='apart'>
|
||||
<Text size='xs' color='dimmed' className={classes.title}>
|
||||
{stat.title}
|
||||
</Text>
|
||||
{stat.icon}
|
||||
</Group>
|
||||
|
||||
<Group align='flex-end' spacing='xs' mt={25}>
|
||||
<Text className={classes.value}>{stat.value}</Text>
|
||||
{
|
||||
typeof stat.diff == 'number' && (
|
||||
<>
|
||||
<Text
|
||||
color={stat.diff >= 0 ? 'teal' : 'red'}
|
||||
size='sm'
|
||||
weight={500}
|
||||
className={classes.diff}
|
||||
>
|
||||
<span>{stat.diff}%</span>
|
||||
{
|
||||
stat.diff >= 0 ? (
|
||||
<ArrowUpRight size={16} />
|
||||
) : (
|
||||
<ArrowDownRight size={16} />
|
||||
)
|
||||
}
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Group>
|
||||
|
||||
<Text size='xs' color='dimmed' mt={7}>
|
||||
{stat.desc}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -93,6 +93,20 @@ export default function ZiplineTheming({ Component, pageProps, ...props }) {
|
|||
transition: 'pop',
|
||||
},
|
||||
},
|
||||
Card: {
|
||||
styles: t => ({
|
||||
root: {
|
||||
backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0],
|
||||
},
|
||||
}),
|
||||
},
|
||||
Image: {
|
||||
styles: t => ({
|
||||
placeholder: {
|
||||
backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
|
7
src/components/icons/ShareXIcon.tsx
Normal file
7
src/components/icons/ShareXIcon.tsx
Normal file
|
@ -0,0 +1,7 @@
|
|||
// https://getsharex.com/brand-assets/
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function ShareXIcon({ ...props }) {
|
||||
return <Image src='https://getsharex.com/img/ShareX_Logo.svg' width={15} height={15} {...props} />;
|
||||
}
|
5
src/components/icons/TrashIcon.tsx
Normal file
5
src/components/icons/TrashIcon.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { Trash2 } from 'react-feather';
|
||||
|
||||
export default function TrashIcon({ ...props }) {
|
||||
return <Trash2 size={15} {...props} />;
|
||||
}
|
|
@ -24,6 +24,7 @@ import HashIcon from './HashIcon';
|
|||
import TagIcon from './TagIcon';
|
||||
import ClockIcon from './ClockIcon';
|
||||
import ExternalLinkIcon from './ExternalLinkIcon';
|
||||
import ShareXIcon from './ShareXIcon';
|
||||
|
||||
export {
|
||||
ActivityIcon,
|
||||
|
@ -52,4 +53,5 @@ export {
|
|||
TagIcon,
|
||||
ClockIcon,
|
||||
ExternalLinkIcon,
|
||||
ShareXIcon,
|
||||
};
|
|
@ -1,190 +0,0 @@
|
|||
import { SimpleGrid, Skeleton, Title, Card as MantineCard, useMantineTheme, Box } from '@mantine/core';
|
||||
import { randomId, useClipboard } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import Card from 'components/Card';
|
||||
import File from 'components/File';
|
||||
import { CopyIcon, CrossIcon, DeleteIcon, EnterIcon } from 'components/icons';
|
||||
import Link from 'components/Link';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { bytesToRead } from 'lib/clientUtils';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import { DataGrid, dateFilterFn, stringFilterFn } from '@dicedtomato/mantine-data-grid';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function Dashboard() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const [images, setImages] = useState([]);
|
||||
const [recent, setRecent] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const updateImages = async () => {
|
||||
const imgs = await useFetch('/api/user/files');
|
||||
const recent = await useFetch('/api/user/recent?filter=media');
|
||||
const stts = await useFetch('/api/stats');
|
||||
setImages(imgs.map(x => ({ ...x, created_at: new Date(x.created_at).toLocaleString() })));
|
||||
setStats(stts);
|
||||
setRecent(recent);
|
||||
};
|
||||
|
||||
const deleteImage = async ({ original }) => {
|
||||
const res = await useFetch('/api/user/files', 'DELETE', { id: original.id });
|
||||
if (!res.error) {
|
||||
updateImages();
|
||||
showNotification({
|
||||
title: 'Image Deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <DeleteIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to delete image',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const copyImage = async ({ original }) => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${original.url}`);
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const viewImage = async ({ original }) => {
|
||||
window.open(`${window.location.protocol}//${window.location.host}${original.url}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateImages();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Welcome back, {user?.username}</Title>
|
||||
<MutedText size='md'>You have <b>{images.length ? images.length : '...'}</b> files</MutedText>
|
||||
|
||||
<Title>Recent Files</Title>
|
||||
<SimpleGrid
|
||||
cols={4}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{recent.length ? recent.map(image => (
|
||||
<File key={randomId()} image={image} updateImages={updateImages} />
|
||||
)) : [1, 2, 3, 4].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Title mt='md'>Stats</Title>
|
||||
<MutedText size='md'>View more stats here <Link href='/dashboard/stats'>here</Link>.</MutedText>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
<Card name='Size' sx={{ height: '100%' }}>
|
||||
<MutedText>{stats ? stats.size : <Skeleton height={8} />}</MutedText>
|
||||
<Title order={2}>Average Size</Title>
|
||||
<MutedText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</MutedText>
|
||||
</Card>
|
||||
<Card name='Images' sx={{ height: '100%' }}>
|
||||
<MutedText>{stats ? stats.count : <Skeleton height={8} />}</MutedText>
|
||||
<Title order={2}>Views</Title>
|
||||
<MutedText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</MutedText>
|
||||
</Card>
|
||||
<Card name='Users' sx={{ height: '100%' }}>
|
||||
<MutedText>{stats ? stats.count_users : <Skeleton height={8} />}</MutedText>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Title mt='md'>Files</Title>
|
||||
<MutedText size='md'>View your gallery <Link href='/dashboard/files'>here</Link>.</MutedText>
|
||||
<DataGrid
|
||||
data={images}
|
||||
loading={images.length ? false : true}
|
||||
withPagination={true}
|
||||
withColumnResizing={false}
|
||||
withColumnFilters={true}
|
||||
noEllipsis={true}
|
||||
withSorting={true}
|
||||
highlightOnHover={true}
|
||||
CopyIcon={CopyIcon}
|
||||
DeleteIcon={DeleteIcon}
|
||||
EnterIcon={EnterIcon}
|
||||
deleteImage={deleteImage}
|
||||
copyImage={copyImage}
|
||||
viewImage={viewImage}
|
||||
styles={{
|
||||
dataCell: {
|
||||
width: '100%',
|
||||
},
|
||||
td: {
|
||||
':nth-child(1)': {
|
||||
minWidth: 170,
|
||||
},
|
||||
':nth-child(2)': {
|
||||
minWidth: 100,
|
||||
},
|
||||
},
|
||||
th: {
|
||||
':nth-child(1)': {
|
||||
minWidth: 170,
|
||||
padding: theme.spacing.lg,
|
||||
borderTopLeftRadius: theme.radius.sm,
|
||||
},
|
||||
':nth-child(2)': {
|
||||
minWidth: 100,
|
||||
padding: theme.spacing.lg,
|
||||
},
|
||||
':nth-child(3)': {
|
||||
padding: theme.spacing.lg,
|
||||
},
|
||||
':nth-child(4)': {
|
||||
padding: theme.spacing.lg,
|
||||
borderTopRightRadius: theme.radius.sm,
|
||||
},
|
||||
},
|
||||
thead: {
|
||||
backgroundColor: theme.colors.dark[6],
|
||||
},
|
||||
}}
|
||||
empty={<></>}
|
||||
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'file',
|
||||
header: 'Name',
|
||||
filterFn: stringFilterFn,
|
||||
},
|
||||
{
|
||||
accessorKey: 'mimetype',
|
||||
header: 'Type',
|
||||
filterFn: stringFilterFn,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Date',
|
||||
filterFn: dateFilterFn,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
55
src/components/pages/Dashboard/RecentFiles.tsx
Normal file
55
src/components/pages/Dashboard/RecentFiles.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { Box, Card as MantineCard, Center, Group, SimpleGrid, Skeleton, Title } from '@mantine/core';
|
||||
import { randomId } from '@mantine/hooks';
|
||||
import File from 'components/File';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { invalidateFiles, useRecent } from 'lib/queries/files';
|
||||
import { UploadCloud } from 'react-feather';
|
||||
|
||||
export default function RecentFiles() {
|
||||
const recent = useRecent('media');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Recent Files</Title>
|
||||
<SimpleGrid
|
||||
cols={(recent.isSuccess && recent.data.length === 0) ? 1 : 4}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{
|
||||
recent.isSuccess
|
||||
? (
|
||||
recent.data.length > 0
|
||||
? (
|
||||
recent.data.map(image => (
|
||||
<File key={randomId()} image={image} updateImages={invalidateFiles} />
|
||||
))
|
||||
) : (
|
||||
<MantineCard shadow='md'>
|
||||
<Center>
|
||||
<Group>
|
||||
<div>
|
||||
<UploadCloud size={48} />
|
||||
</div>
|
||||
<div>
|
||||
<Title>Nothing here</Title>
|
||||
<MutedText size='md'>Upload some files and they will show up here.</MutedText>
|
||||
</div>
|
||||
</Group>
|
||||
</Center>
|
||||
</MantineCard>
|
||||
)
|
||||
) : (
|
||||
[1, 2, 3, 4].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
|
||||
</div>
|
||||
))
|
||||
)
|
||||
}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
61
src/components/pages/Dashboard/StatCards.tsx
Normal file
61
src/components/pages/Dashboard/StatCards.tsx
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { SimpleGrid } from '@mantine/core';
|
||||
import { FileIcon } from 'components/icons';
|
||||
import StatCard from 'components/StatCard';
|
||||
import { percentChange } from 'lib/clientUtils';
|
||||
import { useStats } from 'lib/queries/stats';
|
||||
import { Database, Eye, Users } from 'react-feather';
|
||||
|
||||
export function StatCards() {
|
||||
const stats = useStats();
|
||||
const latest = stats.data?.[0];
|
||||
const before = stats.data?.[1];
|
||||
|
||||
return (
|
||||
<SimpleGrid
|
||||
cols={4}
|
||||
breakpoints={[
|
||||
{ maxWidth: 'md', cols: 2 },
|
||||
{ maxWidth: 'xs', cols: 1 },
|
||||
]}
|
||||
>
|
||||
<StatCard stat={{
|
||||
title: 'UPLOADED FILES',
|
||||
value: stats.isSuccess ? latest.data.count.toLocaleString() : '...',
|
||||
desc: 'files have been uploaded',
|
||||
icon: (
|
||||
<FileIcon />
|
||||
),
|
||||
diff: stats.isSuccess ? percentChange(before.data.count, latest.data.count) : undefined,
|
||||
}}/>
|
||||
|
||||
<StatCard stat={{
|
||||
title: 'STORAGE',
|
||||
value: stats.isSuccess ? latest.data.size : '...',
|
||||
desc: 'of storage used',
|
||||
icon: (
|
||||
<Database size={15} />
|
||||
),
|
||||
diff: stats.isSuccess ? percentChange(before.data.size_num, latest.data.size_num) : undefined,
|
||||
}}/>
|
||||
|
||||
<StatCard stat={{
|
||||
title: 'VIEWS',
|
||||
value: stats.isSuccess ? latest.data.views_count.toLocaleString() : '...',
|
||||
desc: 'total page views',
|
||||
icon: (
|
||||
<Eye size={15} />
|
||||
),
|
||||
diff: stats.isSuccess ? percentChange(before.data.views_count, latest.data.views_count) : undefined,
|
||||
}}/>
|
||||
|
||||
<StatCard stat={{
|
||||
title: 'USERS',
|
||||
value: stats.isSuccess ? latest.data.count_users.toLocaleString() : '...',
|
||||
desc: 'total registered users',
|
||||
icon: (
|
||||
<Users size={15} />
|
||||
),
|
||||
}}/>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
148
src/components/pages/Dashboard/index.tsx
Normal file
148
src/components/pages/Dashboard/index.tsx
Normal file
|
@ -0,0 +1,148 @@
|
|||
import { DataGrid, dateFilterFn, stringFilterFn } from '@dicedtomato/mantine-data-grid';
|
||||
import { Title, useMantineTheme } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { CopyIcon, CrossIcon, DeleteIcon, EnterIcon } from 'components/icons';
|
||||
import Link from 'components/Link';
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { useFiles, useRecent } from 'lib/queries/files';
|
||||
import { useStats } from 'lib/queries/stats';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import RecentFiles from './RecentFiles';
|
||||
import { StatCards } from './StatCards';
|
||||
|
||||
export default function Dashboard() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const images = useFiles();
|
||||
const recent = useRecent('media');
|
||||
const stats = useStats();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const updateImages = () => {
|
||||
images.refetch();
|
||||
recent.refetch();
|
||||
stats.refetch();
|
||||
};
|
||||
|
||||
const deleteImage = async ({ original }) => {
|
||||
const res = await useFetch('/api/user/files', 'DELETE', { id: original.id });
|
||||
if (!res.error) {
|
||||
updateImages();
|
||||
showNotification({
|
||||
title: 'Image Deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <DeleteIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to delete image',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const copyImage = async ({ original }) => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${original.url}`);
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const viewImage = async ({ original }) => {
|
||||
window.open(`${window.location.protocol}//${window.location.host}${original.url}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title>Welcome back, {user?.username}</Title>
|
||||
<MutedText size='md'>You have <b>{images.isSuccess ? images.data.length : '...'}</b> files</MutedText>
|
||||
|
||||
<StatCards />
|
||||
|
||||
<RecentFiles />
|
||||
|
||||
<section>
|
||||
<Title>Files</Title>
|
||||
<MutedText size='md'>View your gallery <Link href='/dashboard/files'>here</Link>.</MutedText>
|
||||
<DataGrid
|
||||
data={images.data ?? []}
|
||||
loading={images.isLoading}
|
||||
withPagination={true}
|
||||
withColumnResizing={false}
|
||||
withColumnFilters={true}
|
||||
noEllipsis={true}
|
||||
withSorting={true}
|
||||
highlightOnHover={true}
|
||||
CopyIcon={CopyIcon}
|
||||
DeleteIcon={DeleteIcon}
|
||||
EnterIcon={EnterIcon}
|
||||
deleteImage={deleteImage}
|
||||
copyImage={copyImage}
|
||||
viewImage={viewImage}
|
||||
styles={{
|
||||
dataCell: {
|
||||
width: '100%',
|
||||
},
|
||||
td: {
|
||||
':nth-child(1)': {
|
||||
minWidth: 170,
|
||||
},
|
||||
':nth-child(2)': {
|
||||
minWidth: 100,
|
||||
},
|
||||
},
|
||||
th: {
|
||||
':nth-child(1)': {
|
||||
minWidth: 170,
|
||||
padding: theme.spacing.lg,
|
||||
borderTopLeftRadius: theme.radius.sm,
|
||||
},
|
||||
':nth-child(2)': {
|
||||
minWidth: 100,
|
||||
padding: theme.spacing.lg,
|
||||
},
|
||||
':nth-child(3)': {
|
||||
padding: theme.spacing.lg,
|
||||
},
|
||||
':nth-child(4)': {
|
||||
padding: theme.spacing.lg,
|
||||
borderTopRightRadius: theme.radius.sm,
|
||||
},
|
||||
},
|
||||
thead: {
|
||||
backgroundColor: theme.colors.dark[6],
|
||||
},
|
||||
}}
|
||||
empty={<></>}
|
||||
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'file',
|
||||
header: 'Name',
|
||||
filterFn: stringFilterFn,
|
||||
},
|
||||
{
|
||||
accessorKey: 'mimetype',
|
||||
header: 'Type',
|
||||
filterFn: stringFilterFn,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Date',
|
||||
filterFn: dateFilterFn,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,103 +0,0 @@
|
|||
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Skeleton, Title } from '@mantine/core';
|
||||
import File from 'components/File';
|
||||
import { PlusIcon } from 'components/icons';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function Files() {
|
||||
const [pages, setPages] = useState([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [favoritePages, setFavoritePages] = useState([]);
|
||||
const [favoritePage, setFavoritePage] = useState(1);
|
||||
|
||||
const updatePages = async favorite => {
|
||||
const pages = await useFetch('/api/user/files?paged=true&filter=media');
|
||||
if (favorite) {
|
||||
const fPages = await useFetch('/api/user/files?paged=true&favorite=media');
|
||||
setFavoritePages(fPages);
|
||||
}
|
||||
setPages(pages);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updatePages(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group mb='md'>
|
||||
<Title>Files</Title>
|
||||
<Link href='/dashboard/upload' passHref>
|
||||
<ActionIcon component='a' variant='filled' color='primary'><PlusIcon/></ActionIcon>
|
||||
</Link>
|
||||
</Group>
|
||||
{favoritePages.length ? (
|
||||
<Accordion
|
||||
variant='contained'
|
||||
mb='sm'
|
||||
>
|
||||
<Accordion.Item value='favorite'>
|
||||
<Accordion.Control>Favorite Files</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{favoritePages.length ? favoritePages[(favoritePage - 1) ?? 0].map(image => (
|
||||
<div key={image.id}>
|
||||
<File image={image} updateImages={() => updatePages(true)} />
|
||||
</div>
|
||||
)) : null}
|
||||
</SimpleGrid>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Pagination total={favoritePages.length} page={favoritePage} onChange={setFavoritePage}/>
|
||||
</Box>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
) : null}
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{pages.length ? pages[(page - 1) ?? 0].map(image => (
|
||||
<div key={image.id}>
|
||||
<File image={image} updateImages={() => updatePages(true)} />
|
||||
</div>
|
||||
)) : [1,2,3,4].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{pages.length ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Pagination total={pages.length} page={page} onChange={setPage}/>
|
||||
</Box>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
73
src/components/pages/Files/FilePagation.tsx
Normal file
73
src/components/pages/Files/FilePagation.tsx
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { Box, Center, Group, Pagination, SimpleGrid, Skeleton, Title } from '@mantine/core';
|
||||
import File from 'components/File';
|
||||
import { FileIcon } from 'components/icons';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { usePaginatedFiles } from 'lib/queries/files';
|
||||
import { Fragment, useState } from 'react';
|
||||
|
||||
export default function FilePagation() {
|
||||
const pages = usePaginatedFiles({ filter: 'media' });
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
if (pages.isSuccess && pages.data.length === 0) {
|
||||
return (
|
||||
<Center>
|
||||
<Group>
|
||||
<div>
|
||||
<FileIcon size={48} />
|
||||
</div>
|
||||
<div>
|
||||
<Title>Nothing here</Title>
|
||||
<MutedText size='md'>Upload some files and they will show up here.</MutedText>
|
||||
</div>
|
||||
</Group>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{
|
||||
(pages.isSuccess)
|
||||
? pages.data.length
|
||||
? (
|
||||
pages.data[(page - 1) ?? 0].map(image => (
|
||||
<div key={image.id}>
|
||||
<File image={image} updateImages={() => pages.refetch()} />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
null
|
||||
)
|
||||
: (
|
||||
[1,2,3,4].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
}
|
||||
</SimpleGrid>
|
||||
{(pages.isSuccess && pages.data.length) ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Pagination total={pages.data?.length ?? 0} page={page} onChange={setPage}/>
|
||||
</Box>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
73
src/components/pages/Files/index.tsx
Normal file
73
src/components/pages/Files/index.tsx
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Title } from '@mantine/core';
|
||||
import File from 'components/File';
|
||||
import { PlusIcon } from 'components/icons';
|
||||
import { usePaginatedFiles } from 'lib/queries/files';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import FilePagation from './FilePagation';
|
||||
|
||||
export default function Files() {
|
||||
const pages = usePaginatedFiles({ filter: 'media' });
|
||||
const favoritePages = usePaginatedFiles({ favorite: 'media' });
|
||||
const [favoritePage, setFavoritePage] = useState(1);
|
||||
|
||||
const updatePages = async favorite => {
|
||||
pages.refetch();
|
||||
|
||||
if (favorite) {
|
||||
favoritePages.refetch();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group mb='md'>
|
||||
<Title>Files</Title>
|
||||
<Link href='/dashboard/upload' passHref>
|
||||
<ActionIcon component='a' variant='filled' color='primary'><PlusIcon/></ActionIcon>
|
||||
</Link>
|
||||
</Group>
|
||||
{
|
||||
(favoritePages.isSuccess && favoritePages.data.length)
|
||||
? (
|
||||
<Accordion
|
||||
variant='contained'
|
||||
mb='sm'
|
||||
>
|
||||
<Accordion.Item value='favorite'>
|
||||
<Accordion.Control>Favorite Files</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{(favoritePages.isSuccess && favoritePages.data.length) ? favoritePages.data[(favoritePage - 1) ?? 0].map(image => (
|
||||
<div key={image.id}>
|
||||
<File image={image} updateImages={() => updatePages(true)} />
|
||||
</div>
|
||||
)) : null}
|
||||
</SimpleGrid>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Pagination total={favoritePages.data.length} page={favoritePage} onChange={setFavoritePage}/>
|
||||
</Box>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
) : null
|
||||
}
|
||||
|
||||
<FilePagation />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { ActionIcon, Avatar, Button, Card, Group, Modal, Select, SimpleGrid, Skeleton, Stack, Switch, TextInput, Title } from '@mantine/core';
|
||||
import { ActionIcon, Avatar, Button, Card, Group, Modal, Select, SimpleGrid, Skeleton, Stack, Title } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useModals } from '@mantine/modals';
|
||||
|
|
134
src/components/pages/Manage/ShareX.tsx
Normal file
134
src/components/pages/Manage/ShareX.tsx
Normal file
|
@ -0,0 +1,134 @@
|
|||
import { Button, Checkbox, Group, Modal, NumberInput, Select, Title } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import DownloadIcon from 'components/icons/DownloadIcon';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ShareX({ user, open, setOpen }) {
|
||||
const [config, setConfig] = useState({
|
||||
Version: '13.2.1',
|
||||
Name: 'Zipline',
|
||||
DestinationType: 'ImageUploader, TextUploader',
|
||||
RequestMethod: 'POST',
|
||||
RequestURL: `${window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : '')}/api/upload`,
|
||||
Headers: {
|
||||
Authorization: user?.token,
|
||||
},
|
||||
URL: '$json:files[0]$',
|
||||
Body: 'MultipartFormData',
|
||||
FileFormName: 'file',
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
format: 'RANDOM',
|
||||
imageCompression: 0,
|
||||
zeroWidthSpace: false,
|
||||
embed: false,
|
||||
},
|
||||
});
|
||||
|
||||
const download = values => {
|
||||
if (values.format !== 'RANDOM') {
|
||||
config.Headers['Format'] = values.format;
|
||||
setConfig(config);
|
||||
} else {
|
||||
delete config.Headers['Format'];
|
||||
setConfig(config);
|
||||
}
|
||||
|
||||
if (values.imageCompression !== 0) {
|
||||
config.Headers['Image-Compression-Percent'] = values.imageCompression;
|
||||
setConfig(config);
|
||||
} else {
|
||||
delete config.Headers['Image-Compression-Percent'];
|
||||
setConfig(config);
|
||||
}
|
||||
|
||||
if (values.zeroWidthSpace) {
|
||||
config.Headers['Zws'] = 'true';
|
||||
setConfig(config);
|
||||
} else {
|
||||
delete config.Headers['Zws'];
|
||||
setConfig(config);
|
||||
}
|
||||
|
||||
if (values.embed) {
|
||||
config.Headers['Embed'] = 'true';
|
||||
setConfig(config);
|
||||
} else {
|
||||
delete config.Headers['Embed'];
|
||||
setConfig(config);
|
||||
}
|
||||
|
||||
const pseudoElement = document.createElement('a');
|
||||
pseudoElement.setAttribute('href', 'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(config, null, '\t')));
|
||||
pseudoElement.setAttribute('download', 'zipline.sxcu');
|
||||
pseudoElement.style.display = 'none';
|
||||
document.body.appendChild(pseudoElement);
|
||||
pseudoElement.click();
|
||||
pseudoElement.parentNode.removeChild(pseudoElement);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Title order={3}>ShareX</Title>}
|
||||
size='lg'
|
||||
>
|
||||
|
||||
<form onSubmit={form.onSubmit(values => download(values))}>
|
||||
<Select
|
||||
label='Select file name format'
|
||||
data={[
|
||||
{ value: 'RANDOM', label: 'Random (alphanumeric)' },
|
||||
{ value: 'DATE', label: 'Date' },
|
||||
{ value: 'UUID', label: 'UUID' },
|
||||
{ value: 'NAME', label: 'Name (keeps original file name)' },
|
||||
]}
|
||||
id='format'
|
||||
{...form.getInputProps('format')}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={'Image Compression (leave at 0 if you don\'t want to compress)'}
|
||||
max={100}
|
||||
min={0}
|
||||
mt='md'
|
||||
id='imageCompression'
|
||||
{...form.getInputProps('imageCompression')}
|
||||
/>
|
||||
|
||||
<Group grow mt='md'>
|
||||
<Checkbox
|
||||
label='Zero Width Space'
|
||||
id='zeroWidthSpace'
|
||||
{...form.getInputProps('zeroWidthSpace', { type: 'checkbox' })}
|
||||
/>
|
||||
<Checkbox
|
||||
label='Embed'
|
||||
id='embed'
|
||||
{...form.getInputProps('embed', { type: 'checkbox' })}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow>
|
||||
<Button
|
||||
mt='md'
|
||||
onClick={form.reset}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
mt='md'
|
||||
rightIcon={<DownloadIcon />}
|
||||
type='submit'
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -1,18 +1,19 @@
|
|||
import { Box, Button, Card, ColorInput, Group, MultiSelect, Space, Text, TextInput, PasswordInput, Title, Tooltip, FileInput, Image } from '@mantine/core';
|
||||
import { randomId, useInterval } from '@mantine/hooks';
|
||||
import { Box, Button, Card, ColorInput, FileInput, Group, Image, PasswordInput, Space, Text, TextInput, Title, Tooltip } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { randomId, useInterval } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import { CrossIcon, DeleteIcon, SettingsIcon } from 'components/icons';
|
||||
import { CrossIcon, DeleteIcon, SettingsIcon, ShareXIcon } from 'components/icons';
|
||||
import DownloadIcon from 'components/icons/DownloadIcon';
|
||||
import Link from 'components/Link';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { SmallTable } from 'components/SmallTable';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { bytesToRead } from 'lib/clientUtils';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
|
||||
import { useEffect, useState } from 'react';
|
||||
import MutedText from 'components/MutedText';
|
||||
import ShareX from './ShareX';
|
||||
|
||||
function ExportDataTooltip({ children }) {
|
||||
return <Tooltip position='top' color='' label='After clicking, if you have a lot of files the export can take a while to complete. A list of previous exports will be below to download.'>{children}</Tooltip>;
|
||||
|
@ -23,6 +24,7 @@ export default function Manage() {
|
|||
const dispatch = useStoreDispatch();
|
||||
const modals = useModals();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [exports, setExports] = useState([]);
|
||||
const [file, setFile] = useState<File>(null);
|
||||
const [fileDataURL, setFileDataURL] = useState(user.avatar ?? null);
|
||||
|
@ -82,32 +84,6 @@ export default function Manage() {
|
|||
}
|
||||
};
|
||||
|
||||
const genShareX = (withEmbed: boolean = false, withZws: boolean = false) => {
|
||||
const config = {
|
||||
Version: '13.2.1',
|
||||
Name: 'Zipline',
|
||||
DestinationType: 'ImageUploader, TextUploader',
|
||||
RequestMethod: 'POST',
|
||||
RequestURL: `${window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : '')}/api/upload`,
|
||||
Headers: {
|
||||
Authorization: user?.token,
|
||||
...(withEmbed && { Embed: 'true' }),
|
||||
...(withZws && { ZWS: 'true' }),
|
||||
},
|
||||
URL: '$json:files[0]$',
|
||||
Body: 'MultipartFormData',
|
||||
FileFormName: 'file',
|
||||
};
|
||||
|
||||
const pseudoElement = document.createElement('a');
|
||||
pseudoElement.setAttribute('href', 'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(config, null, '\t')));
|
||||
pseudoElement.setAttribute('download', `zipline${withEmbed ? '_embed' : ''}${withZws ? '_zws' : ''}.sxcu`);
|
||||
pseudoElement.style.display = 'none';
|
||||
document.body.appendChild(pseudoElement);
|
||||
pseudoElement.click();
|
||||
pseudoElement.parentNode.removeChild(pseudoElement);
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: user.username,
|
||||
|
@ -323,10 +299,10 @@ export default function Manage() {
|
|||
|
||||
<Title my='md'>ShareX Config</Title>
|
||||
<Group>
|
||||
<Button onClick={() => genShareX(false)} rightIcon={<DownloadIcon />}>ShareX Config</Button>
|
||||
<Button onClick={() => genShareX(true)} rightIcon={<DownloadIcon />}>ShareX Config with Embed</Button>
|
||||
<Button onClick={() => genShareX(false, true)} rightIcon={<DownloadIcon />}>ShareX Config with ZWS</Button>
|
||||
<Button onClick={() => setOpen(true)} rightIcon={<ShareXIcon />}>Generate ShareX Config</Button>
|
||||
</Group>
|
||||
|
||||
<ShareX user={user} open={open} setOpen={setOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
import { SimpleGrid, Skeleton, Title } from '@mantine/core';
|
||||
import Card from 'components/Card';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { SmallTable } from 'components/SmallTable';
|
||||
import { bytesToRead } from 'lib/clientUtils';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function Stats() {
|
||||
const [stats, setStats] = useState(null);
|
||||
|
||||
const update = async () => {
|
||||
const stts = await useFetch('/api/stats');
|
||||
setStats(stts);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
update();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title mb='md'>Stats</Title>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
<Card name='Size' sx={{ height: '100%' }}>
|
||||
<MutedText>{stats ? stats.size : <Skeleton height={8} />}</MutedText>
|
||||
<Title order={2}>Average Size</Title>
|
||||
<MutedText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</MutedText>
|
||||
</Card>
|
||||
<Card name='Images' sx={{ height: '100%' }}>
|
||||
<MutedText>{stats ? stats.count : <Skeleton height={8} />}</MutedText>
|
||||
<Title order={2}>Views</Title>
|
||||
<MutedText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</MutedText>
|
||||
</Card>
|
||||
<Card name='Users' sx={{ height: '100%' }}>
|
||||
<MutedText>{stats ? stats.count_users : <Skeleton height={8} />}</MutedText>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{stats && stats.count_by_user.length ? (
|
||||
<Card name='Files per User' mt={22}>
|
||||
<SmallTable
|
||||
columns={[
|
||||
{ id: 'username', name: 'Name' },
|
||||
{ id: 'count', name: 'Files' },
|
||||
]}
|
||||
rows={stats ? stats.count_by_user : []} />
|
||||
</Card>
|
||||
) : null}
|
||||
<Card name='Types' mt={22}>
|
||||
<SmallTable
|
||||
columns={[
|
||||
{ id: 'mimetype', name: 'Type' },
|
||||
{ id: 'count', name: 'Count' },
|
||||
]}
|
||||
rows={stats ? stats.types_count : []} />
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
235
src/components/pages/Stats/Graphs.tsx
Normal file
235
src/components/pages/Stats/Graphs.tsx
Normal file
|
@ -0,0 +1,235 @@
|
|||
import { Box, Card, Grid, LoadingOverlay, MantineTheme, Title, useMantineTheme } from '@mantine/core';
|
||||
import { ArcElement, CategoryScale, Chart as ChartJS, ChartData, ChartOptions, LinearScale, LineController, LineElement, PointElement, Tooltip } from 'chart.js';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
import ColorHash from 'color-hash';
|
||||
import { bytesToRead } from 'lib/clientUtils';
|
||||
import { useStats } from 'lib/queries/stats';
|
||||
import { useMemo } from 'react';
|
||||
import { Chart, Pie } from 'react-chartjs-2';
|
||||
|
||||
const hash = new ColorHash();
|
||||
ChartJS.register(ArcElement);
|
||||
ChartJS.register(ChartDataLabels);
|
||||
ChartJS.register(LinearScale);
|
||||
ChartJS.register(CategoryScale, PointElement, LineController, LineElement, Tooltip);
|
||||
|
||||
const CHART_OPTIONS = (theme: MantineTheme): ChartOptions => ({
|
||||
plugins: {
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
datalabels: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
|
||||
scales: {
|
||||
y: {
|
||||
ticks: {
|
||||
callback: (value) => value.toLocaleString(),
|
||||
color: theme.colors.gray[6],
|
||||
},
|
||||
|
||||
grid: {
|
||||
color: theme.colors.gray[8],
|
||||
},
|
||||
},
|
||||
|
||||
x: {
|
||||
ticks: {
|
||||
color: theme.colors.gray[6],
|
||||
},
|
||||
|
||||
grid: {
|
||||
color: theme.colors.gray[8],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
type LineChartData = ChartData<'line', number[], string>;
|
||||
type ChartDataMemo = {
|
||||
views: LineChartData,
|
||||
uploads: LineChartData,
|
||||
uploadTypes: ChartData<'pie', number[], string>,
|
||||
storage: LineChartData,
|
||||
} | void;
|
||||
|
||||
export default function Graphs() {
|
||||
const historicalStats = useStats(10);
|
||||
const latest = historicalStats.data?.[0];
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const chartOptions = useMemo(() => CHART_OPTIONS(theme), [theme]);
|
||||
|
||||
const chartData = useMemo<ChartDataMemo>(() => {
|
||||
if (historicalStats.isLoading || !historicalStats.data) return;
|
||||
|
||||
const data = Array.from(historicalStats.data).reverse();
|
||||
const labels = data.map((stat) => new Date(stat.created_at).toLocaleDateString());
|
||||
const viewData = data.map((stat) => stat.data.views_count);
|
||||
const uploadData = data.map((stat) => stat.data.count);
|
||||
const storageData = data.map((stat) => stat.data.size_num);
|
||||
|
||||
return {
|
||||
views: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Views',
|
||||
data: viewData,
|
||||
borderColor: theme.colors.blue[6],
|
||||
backgroundColor: theme.colors.blue[0],
|
||||
}],
|
||||
},
|
||||
|
||||
uploads: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Uploads',
|
||||
data: uploadData,
|
||||
borderColor: theme.colors.blue[6],
|
||||
backgroundColor: theme.colors.blue[0],
|
||||
}],
|
||||
},
|
||||
|
||||
uploadTypes: {
|
||||
labels: latest?.data.types_count.map((x) => x.mimetype),
|
||||
datasets: [{
|
||||
data: latest?.data.types_count.map((x) => x.count),
|
||||
label: 'Upload Types',
|
||||
backgroundColor: latest?.data.types_count.map((x) => hash.hex(x.mimetype)),
|
||||
}],
|
||||
},
|
||||
|
||||
storage: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Storage',
|
||||
data: storageData,
|
||||
borderColor: theme.colors.blue[6],
|
||||
backgroundColor: theme.colors.blue[0],
|
||||
}],
|
||||
},
|
||||
};
|
||||
}, [historicalStats]);
|
||||
|
||||
return (
|
||||
<Box mt='md'>
|
||||
<LoadingOverlay visible={historicalStats.isLoading} />
|
||||
|
||||
<Grid>
|
||||
{/* 1/4 - upload types */}
|
||||
<Grid.Col md={12} lg={4}>
|
||||
<Card>
|
||||
<Title size='h4'>Upload Types</Title>
|
||||
{
|
||||
chartData && (
|
||||
<Pie
|
||||
data={chartData.uploadTypes}
|
||||
|
||||
options={{
|
||||
plugins: {
|
||||
datalabels: {
|
||||
formatter: (_, ctx) => {
|
||||
// mime: count
|
||||
const mime = ctx.chart.data.labels[ctx.dataIndex];
|
||||
const count = ctx.chart.data.datasets[0].data[ctx.dataIndex];
|
||||
return `${mime}: ${count}`;
|
||||
},
|
||||
|
||||
color: 'white',
|
||||
textShadowBlur: 7,
|
||||
textShadowColor: 'black',
|
||||
},
|
||||
},
|
||||
}}
|
||||
style={{ maxHeight: '20vh' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
{/* 3/4 - views */}
|
||||
<Grid.Col md={12} lg={8}>
|
||||
<Card>
|
||||
<Title size='h4'>Total Views</Title>
|
||||
{
|
||||
chartData && (
|
||||
<Chart
|
||||
type='line'
|
||||
data={chartData.views}
|
||||
options={chartOptions}
|
||||
style={{ maxHeight: '20vh' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
{/* 1/2 - uploaded files */}
|
||||
<Grid.Col md={12} lg={6}>
|
||||
<Card>
|
||||
<Title size='h4'>Total Uploads</Title>
|
||||
{
|
||||
chartData && (
|
||||
<Chart
|
||||
type='line'
|
||||
data={chartData.uploads}
|
||||
options={chartOptions}
|
||||
style={{ maxHeight: '20vh' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
{/* 1/2 - storage used */}
|
||||
<Grid.Col md={12} lg={6}>
|
||||
<Card>
|
||||
<Title size='h4'>Storage Usage</Title>
|
||||
{
|
||||
chartData && (
|
||||
<Chart
|
||||
type='line'
|
||||
data={chartData.storage}
|
||||
options={{
|
||||
...chartOptions,
|
||||
|
||||
scales: {
|
||||
...chartOptions.scales,
|
||||
y: {
|
||||
...chartOptions.scales.y,
|
||||
|
||||
ticks: {
|
||||
callback: (value) => bytesToRead(value as number),
|
||||
color: theme.colors.gray[6],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
plugins: {
|
||||
...chartOptions.plugins,
|
||||
tooltip: {
|
||||
...chartOptions.plugins.tooltip,
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const value = context.raw as number;
|
||||
return bytesToRead(value);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
style={{ maxHeight: '20vh' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
38
src/components/pages/Stats/Types.tsx
Normal file
38
src/components/pages/Stats/Types.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { LoadingOverlay, Card, Box } from '@mantine/core';
|
||||
import { SmallTable } from 'components/SmallTable';
|
||||
import { useStats } from 'lib/queries/stats';
|
||||
|
||||
export default function Types() {
|
||||
const stats = useStats();
|
||||
|
||||
if(stats.isLoading) return (
|
||||
<LoadingOverlay visible />
|
||||
);
|
||||
|
||||
const latest = stats.data[0];
|
||||
|
||||
return (
|
||||
<Box mt='md'>
|
||||
{latest.data.count_by_user.length ? (
|
||||
<Card>
|
||||
<SmallTable
|
||||
columns={[
|
||||
{ id: 'username', name: 'Name' },
|
||||
{ id: 'count', name: 'Files' },
|
||||
]}
|
||||
rows={latest.data.count_by_user}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
<Card>
|
||||
<SmallTable
|
||||
columns={[
|
||||
{ id: 'mimetype', name: 'Type' },
|
||||
{ id: 'count', name: 'Count' },
|
||||
]}
|
||||
rows={latest.data.types_count}
|
||||
/>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
19
src/components/pages/Stats/index.tsx
Normal file
19
src/components/pages/Stats/index.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { Title } from '@mantine/core';
|
||||
import { StatCards } from '../Dashboard/StatCards';
|
||||
import Graphs from './Graphs';
|
||||
import Types from './Types';
|
||||
|
||||
export default function Stats() {
|
||||
return (
|
||||
<div>
|
||||
<Title mb='md'>Stats</Title>
|
||||
|
||||
<StatCards />
|
||||
|
||||
<Types />
|
||||
|
||||
<Graphs />
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -5,6 +5,7 @@ import Dropzone from 'components/dropzone/Dropzone';
|
|||
import FileDropzone from 'components/dropzone/DropzoneFile';
|
||||
import { ClockIcon, CrossIcon, UploadIcon } from 'components/icons';
|
||||
import Link from 'components/Link';
|
||||
import { invalidateFiles } from 'lib/queries/files';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
|
@ -126,6 +127,7 @@ export default function Upload() {
|
|||
});
|
||||
clipboard.copy(json.files[0]);
|
||||
setFiles([]);
|
||||
invalidateFiles();
|
||||
} else {
|
||||
updateNotification({
|
||||
id: 'upload',
|
||||
|
|
67
src/components/pages/Urls/URLCard.tsx
Normal file
67
src/components/pages/Urls/URLCard.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { ActionIcon, Card, Group, LoadingOverlay, Title } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { CopyIcon, CrossIcon, DeleteIcon, ExternalLinkIcon } from 'components/icons';
|
||||
import TrashIcon from 'components/icons/TrashIcon';
|
||||
import { URLResponse, useURLDelete } from 'lib/queries/url';
|
||||
|
||||
export default function URLCard({ url }: {
|
||||
url: URLResponse
|
||||
}) {
|
||||
const clipboard = useClipboard();
|
||||
const urlDelete = useURLDelete();
|
||||
|
||||
const copyURL = u => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const deleteURL = async u => {
|
||||
urlDelete.mutate(u.id, {
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'Deleted URL',
|
||||
message: '',
|
||||
icon: <CrossIcon />,
|
||||
color: 'green',
|
||||
});
|
||||
},
|
||||
|
||||
onError: (url: any) => {
|
||||
showNotification({
|
||||
title: 'Failed to delete URL',
|
||||
message: url.error,
|
||||
icon: <DeleteIcon />,
|
||||
color: 'red',
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card key={url.id} sx={{ maxWidth: '100%' }} shadow='sm'>
|
||||
<LoadingOverlay visible={urlDelete.isLoading}/>
|
||||
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Title>{url.vanity ?? url.id}</Title>
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
<ActionIcon href={url.url} component='a' target='_blank'>
|
||||
<ExternalLinkIcon />
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='copy' onClick={() => copyURL(url)}>
|
||||
<CopyIcon />
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='delete' onClick={() => deleteURL(url)}>
|
||||
<TrashIcon />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -1,54 +1,20 @@
|
|||
import { ActionIcon, Button, Card, Group, Modal, SimpleGrid, Skeleton, TextInput, Title } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { ActionIcon, Button, Group, Modal, SimpleGrid, Skeleton, TextInput, Title, Card, Center } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { CopyIcon, CrossIcon, DeleteIcon, LinkIcon, PlusIcon } from 'components/icons';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { CrossIcon, LinkIcon, PlusIcon } from 'components/icons';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useURLs } from 'lib/queries/url';
|
||||
import URLCard from './URLCard';
|
||||
import MutedText from 'components/MutedText';
|
||||
|
||||
export default function Urls() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const [urls, setURLS] = useState([]);
|
||||
const urls = useURLs();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const updateURLs = async () => {
|
||||
const urls = await useFetch('/api/user/urls');
|
||||
|
||||
setURLS(urls);
|
||||
};
|
||||
|
||||
const deleteURL = async u => {
|
||||
const url = await useFetch('/api/user/urls', 'DELETE', { id: u.id });
|
||||
if (url.error) {
|
||||
showNotification({
|
||||
title: 'Failed to delete URL',
|
||||
message: url.error,
|
||||
icon: <DeleteIcon />,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Deleted URL',
|
||||
message: '',
|
||||
icon: <CrossIcon />,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
|
||||
updateURLs();
|
||||
};
|
||||
|
||||
const copyURL = u => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
const updateURLs = async () => urls.refetch();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
|
@ -131,6 +97,24 @@ export default function Urls() {
|
|||
<ActionIcon variant='filled' color='primary' onClick={() => setCreateOpen(true)}><PlusIcon /></ActionIcon>
|
||||
</Group>
|
||||
|
||||
{
|
||||
(urls.data && urls.data.length === 0) && (
|
||||
<Card shadow='md'>
|
||||
<Center>
|
||||
<Group>
|
||||
<div>
|
||||
<LinkIcon size={48} />
|
||||
</div>
|
||||
<div>
|
||||
<Title>Nothing here</Title>
|
||||
<MutedText size='md'>Create a link to get started!</MutedText>
|
||||
</div>
|
||||
</Group>
|
||||
</Center>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
<SimpleGrid
|
||||
cols={4}
|
||||
spacing='lg'
|
||||
|
@ -138,26 +122,15 @@ export default function Urls() {
|
|||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{urls.length ? urls.map(url => (
|
||||
<Card key={url.id} sx={{ maxWidth: '100%' }} shadow='sm'>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Title>{url.vanity ?? url.id}</Title>
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
<ActionIcon href={url.url} component='a' target='_blank'><LinkIcon /></ActionIcon>
|
||||
<ActionIcon aria-label='copy' onClick={() => copyURL(url)}>
|
||||
<CopyIcon />
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='delete' onClick={() => deleteURL(url)}>
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)) : [1, 2, 3, 4].map(x => (
|
||||
<Skeleton key={x} width='100%' height={80} radius='sm' />
|
||||
))}
|
||||
{
|
||||
(urls.isLoading || !urls.data) ?
|
||||
[1, 2, 3, 4].map(x => (
|
||||
<Skeleton key={x} width='100%' height={80} radius='sm' />
|
||||
))
|
||||
: urls.data.map(url => (
|
||||
<URLCard key={url.id} url={url} />
|
||||
))
|
||||
}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
|
@ -1,4 +1,4 @@
|
|||
import type { Image, ImageFormat, User } from '@prisma/client';
|
||||
import type { Image, User } from '@prisma/client';
|
||||
import ms, { StringValue } from 'ms';
|
||||
|
||||
export function parse(str: string, image: Image, user: User) {
|
||||
|
@ -82,4 +82,8 @@ export function parseExpiry(header: string): Date | null {
|
|||
if (human.getTime() < Date.now()) return null;
|
||||
|
||||
return human;
|
||||
}
|
||||
|
||||
export function percentChange(initial: number, final: number) {
|
||||
return ((final - initial) / initial) * 100;
|
||||
}
|
|
@ -16,8 +16,12 @@ export class S3 extends Datasource {
|
|||
accessKey: config.access_key_id,
|
||||
secretKey: config.secret_access_key,
|
||||
pathStyle: config.force_s3_path,
|
||||
port: 9000,
|
||||
useSSL: false,
|
||||
region: config.region,
|
||||
});
|
||||
|
||||
// this.s3.
|
||||
}
|
||||
|
||||
public async save(file: string, data: Buffer): Promise<void> {
|
||||
|
|
4
src/lib/queries/client.ts
Normal file
4
src/lib/queries/client.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { QueryClient } from '@tanstack/react-query';
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export default queryClient;
|
87
src/lib/queries/files.ts
Normal file
87
src/lib/queries/files.ts
Normal file
|
@ -0,0 +1,87 @@
|
|||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import queryClient from './client';
|
||||
|
||||
export type UserFilesResponse = {
|
||||
created_at: string;
|
||||
expires_at?: string;
|
||||
file: string;
|
||||
mimetype: string;
|
||||
id: string;
|
||||
favorite: boolean;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const useFiles = (query: { [key: string]: string } = {}) => {
|
||||
const queryBuilder = new URLSearchParams(query);
|
||||
const queryString = queryBuilder.toString();
|
||||
|
||||
return useQuery<UserFilesResponse[]>(['files', queryString], async () => {
|
||||
return fetch('/api/user/files?' + queryString)
|
||||
.then(res => res.json() as Promise<UserFilesResponse[]>)
|
||||
.then(data =>
|
||||
query.paged === 'true'
|
||||
? data
|
||||
: data.map(x => ({
|
||||
...x,
|
||||
created_at: new Date(x.created_at).toLocaleString(),
|
||||
}))
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const usePaginatedFiles = (query: { [key: string]: string } = {}) => {
|
||||
query['paged'] = 'true';
|
||||
const data = useFiles(query) as ReturnType<typeof useQuery> & { data: UserFilesResponse[][] };
|
||||
return data;
|
||||
};
|
||||
|
||||
export const useRecent = (filter?: string) => {
|
||||
return useQuery<UserFilesResponse[]>(['recent', filter], async () => {
|
||||
return fetch(`/api/user/recent?filter=${encodeURIComponent(filter)}`)
|
||||
.then(res => res.json())
|
||||
.then(data => data.map(x => ({
|
||||
...x,
|
||||
created_at: new Date(x.created_at).toLocaleString(),
|
||||
})));
|
||||
});
|
||||
};
|
||||
|
||||
export function useFileDelete() {
|
||||
// '/api/user/files', 'DELETE', { id: image.id }
|
||||
return useMutation(async (id: string) => {
|
||||
return fetch('/api/user/files', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ id }),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
}).then(res => res.json());
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries(['files']);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useFileFavorite() {
|
||||
// /api/user/files', 'PATCH', { id: image.id, favorite: !image.favorite }
|
||||
return useMutation(async (data: { id: string, favorite: boolean }) => {
|
||||
return fetch('/api/user/files', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
}).then(res => res.json());
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries(['files']);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateFiles() {
|
||||
return queryClient.invalidateQueries(
|
||||
['files', 'recent', 'stats']
|
||||
);
|
||||
}
|
29
src/lib/queries/stats.ts
Normal file
29
src/lib/queries/stats.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
type StatsTypesCount = {
|
||||
count: number;
|
||||
mimetype: string;
|
||||
};
|
||||
|
||||
export type Stats = {
|
||||
created_at: string;
|
||||
id: number;
|
||||
data: {
|
||||
count: number;
|
||||
count_by_user: any[];
|
||||
count_users: number;
|
||||
size: string;
|
||||
size_num: number;
|
||||
types_count: StatsTypesCount[];
|
||||
views_count: number;
|
||||
}
|
||||
}
|
||||
|
||||
export const useStats = (amount = 2) => {
|
||||
return useQuery<Stats[]>(['stats', amount], async () => {
|
||||
return fetch('/api/stats?amount=' + amount)
|
||||
.then(res => res.json());
|
||||
}, {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
};
|
35
src/lib/queries/url.ts
Normal file
35
src/lib/queries/url.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import queryClient from './client';
|
||||
|
||||
export type URLResponse = {
|
||||
created_at: string;
|
||||
destination: string;
|
||||
id: string;
|
||||
url: string;
|
||||
vanity: string;
|
||||
}
|
||||
|
||||
export function useURLs() {
|
||||
return useQuery<URLResponse[]>(['urls'], async () => {
|
||||
return fetch('/api/user/urls')
|
||||
.then(res => res.json());
|
||||
});
|
||||
}
|
||||
|
||||
export function useURLDelete() {
|
||||
return useMutation(async (id: string) => {
|
||||
// '/api/user/urls', 'DELETE', { id: u.id }
|
||||
return fetch('/api/user/urls', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ id }),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
}).then(res => res.json());
|
||||
}, {
|
||||
onSuccess: (data, variables) => {
|
||||
const dataWithoutDeleted = queryClient.getQueryData<URLResponse[]>(['urls'])?.filter(u => u.id !== variables);
|
||||
queryClient.setQueryData(['urls'], dataWithoutDeleted);
|
||||
},
|
||||
});
|
||||
}
|
9
src/lib/queries/version.ts
Normal file
9
src/lib/queries/version.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export const useVersion = () => {
|
||||
return useQuery<{ local: string, upstream: string }>(['version'], async () => {
|
||||
return fetch('/api/version').then(res => res.json());
|
||||
}, {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
};
|
10
src/lib/utils/streams.ts
Normal file
10
src/lib/utils/streams.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { Readable } from 'stream';
|
||||
|
||||
// stream to string
|
||||
export const streamToString = (stream: Readable) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk) => chunks.push(chunk));
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
stream.on('error', reject);
|
||||
});
|
|
@ -3,6 +3,8 @@ import { Provider } from 'react-redux';
|
|||
import Head from 'next/head';
|
||||
import { store } from 'lib/redux/store';
|
||||
import ZiplineTheming from 'components/Theming';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import queryClient from 'lib/queries/client';
|
||||
|
||||
export default function MyApp({ Component, pageProps }) {
|
||||
return (
|
||||
|
@ -10,7 +12,11 @@ export default function MyApp({ Component, pageProps }) {
|
|||
<Head>
|
||||
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width' />
|
||||
</Head>
|
||||
<ZiplineTheming Component={Component} pageProps={pageProps} />
|
||||
<QueryClientProvider
|
||||
client={queryClient}
|
||||
>
|
||||
<ZiplineTheming Component={Component} pageProps={pageProps} />
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
|
@ -1,23 +1,37 @@
|
|||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
import prisma from 'lib/prisma';
|
||||
import config from 'lib/config';
|
||||
import { Stats } from '@prisma/client';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const user = await req.user();
|
||||
if (!user) return res.forbid('not logged in');
|
||||
|
||||
const stats = await prisma.stats.findFirst({
|
||||
orderBy: {
|
||||
created_at: 'desc',
|
||||
},
|
||||
take: 1,
|
||||
});
|
||||
let amount = typeof req.query.amount === 'string' ? parseInt(req.query.amount) : 2;
|
||||
if(isNaN(amount)) return res.bad('invalid amount');
|
||||
|
||||
// get stats per day
|
||||
|
||||
var stats = await prisma.$queryRaw<Stats[]>`
|
||||
SELECT *
|
||||
FROM "Stats" as t JOIN
|
||||
(SELECT MAX(t2."created_at") as max_timestamp
|
||||
FROM "Stats" t2
|
||||
GROUP BY date(t2."created_at")
|
||||
) t2
|
||||
ON t."created_at" = t2.max_timestamp
|
||||
ORDER BY t."created_at" DESC
|
||||
LIMIT ${amount}
|
||||
`;
|
||||
|
||||
if (config.website.show_files_per_user) {
|
||||
(stats.data as any).count_by_user = [];
|
||||
stats = stats.map((stat) => {
|
||||
(stat.data as any).count_by_user = [];
|
||||
return stat;
|
||||
});
|
||||
}
|
||||
|
||||
return res.json(stats.data);
|
||||
return res.json(stats);
|
||||
}
|
||||
|
||||
export default withZipline(handler);
|
|
@ -8,7 +8,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||
if (!user) return res.forbid('not logged in');
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
if (!req.body.id) return res.error('no url id');
|
||||
if (!req.body.id) return res.bad('no url id');
|
||||
|
||||
const url = await prisma.url.delete({
|
||||
where: {
|
||||
|
|
|
@ -1,23 +1,48 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import exts from 'lib/exts';
|
||||
import { Prism } from '@mantine/prism';
|
||||
import exts from 'lib/exts';
|
||||
import { streamToString } from 'lib/utils/streams';
|
||||
import { GetServerSideProps } from 'next';
|
||||
|
||||
export default function Code() {
|
||||
const [prismRenderCode, setPrismRenderCode] = React.useState('');
|
||||
const router = useRouter();
|
||||
const { id } = router.query as { id: string };
|
||||
type CodeProps = {
|
||||
code: string,
|
||||
id: string,
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetch('/r/' + id);
|
||||
if (id && !res.ok) await 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;
|
||||
}
|
||||
// Code component
|
||||
export default function Code(
|
||||
{ code, id }: CodeProps
|
||||
) {
|
||||
return (
|
||||
<Prism
|
||||
sx={t => ({ height: '100vh', backgroundColor: t.colors.dark[8] })}
|
||||
withLineNumbers
|
||||
language={exts[id.split('.').pop()]?.toLowerCase()}
|
||||
>
|
||||
{code}
|
||||
</Prism>
|
||||
);
|
||||
}
|
||||
|
||||
// handle server-side rendering
|
||||
export const getServerSideProps: GetServerSideProps<CodeProps> = async (context) => {
|
||||
if (process.env.ZIPLINE_DOCKER_BUILD) return { props: { code: '', id: '' } };
|
||||
|
||||
const { default: datasource } = await import('lib/datasource');
|
||||
|
||||
const data = await datasource.get(context.params.id as string);
|
||||
if (!data) return {
|
||||
notFound: true,
|
||||
};
|
||||
|
||||
context.res.setHeader(
|
||||
'Cache-Control',
|
||||
'public, max-age=2628000, stale-while-revalidate=86400'
|
||||
);
|
||||
|
||||
return {
|
||||
props: {
|
||||
code: await streamToString(data),
|
||||
id: context.params.id as string,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -198,7 +198,7 @@ async function fileDb(
|
|||
if (Object.keys(exts).includes(ext)) return handle(req, res as ServerResponse);
|
||||
|
||||
const data = await datasource.get(image.file);
|
||||
if (!data) return this.nextServer.render404(req, res as ServerResponse);
|
||||
if (!data) return nextServer.render404(req, res as ServerResponse);
|
||||
|
||||
const size = await datasource.size(image.file);
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue