1
Fork 0
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:
Derock 2022-09-23 21:19:27 -04:00 committed by GitHub
parent 722372c7f6
commit dc926e9f5a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 4661 additions and 653 deletions

14
.gitattributes vendored Normal file
View 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
View file

@ -0,0 +1 @@
16.16.0

4
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,4 @@
{
"editor.tabSize": 2,
"files.eol": "\n"
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

@ -0,0 +1,5 @@
import { Trash2 } from 'react-feather';
export default function TrashIcon({ ...props }) {
return <Trash2 size={15} {...props} />;
}

View file

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

View file

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

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

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

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

View file

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

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

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

View file

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

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

View file

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

View file

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

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

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

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

View file

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

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

3329
yarn.lock

File diff suppressed because it is too large Load diff