feat: file size (#308)

* feat: baseline support for file sizes

* feat: script to add file sizes
This commit is contained in:
dicedtomato 2023-03-03 20:40:28 -08:00 committed by GitHub
parent 8e44b71614
commit 912e439645
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 460 additions and 6 deletions

View file

@ -23,7 +23,8 @@
"scripts:import-dir": "node --enable-source-maps dist/scripts/import-dir", "scripts:import-dir": "node --enable-source-maps dist/scripts/import-dir",
"scripts:list-users": "node --enable-source-maps dist/scripts/list-users", "scripts:list-users": "node --enable-source-maps dist/scripts/list-users",
"scripts:set-user": "node --enable-source-maps dist/scripts/set-user", "scripts:set-user": "node --enable-source-maps dist/scripts/set-user",
"scripts:clear-zero-byte": "node --enable-source-maps dist/scripts/clear-zero-byte" "scripts:clear-zero-byte": "node --enable-source-maps dist/scripts/clear-zero-byte",
"scripts:query-size": "node --enable-source-maps dist/scripts/query-size"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.10.6", "@emotion/react": "^11.10.6",

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "File" ADD COLUMN "size" INTEGER NOT NULL DEFAULT 0;

View file

@ -53,6 +53,7 @@ model File {
originalName String? originalName String?
mimetype String @default("image/png") mimetype String @default("image/png")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
size Int @default(0)
expiresAt DateTime? expiresAt DateTime?
maxViews Int? maxViews Int?
views Int @default(0) views Int @default(0)

398
src/components/File.tsx Normal file
View file

@ -0,0 +1,398 @@
import {
ActionIcon,
Card,
Group,
LoadingOverlay,
Modal,
Select,
SimpleGrid,
Stack,
Text,
Title,
Tooltip,
} from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import useFetch from 'hooks/useFetch';
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
import { useFolders } from 'lib/queries/folders';
import { bytesToHuman } from 'lib/utils/bytes';
import { relativeTime } from 'lib/utils/client';
import { useState } from 'react';
import {
CalendarIcon,
ClockIcon,
CopyIcon,
CrossIcon,
DeleteIcon,
DownloadIcon,
ExternalLinkIcon,
EyeIcon,
HardDriveIcon,
FileIcon,
FolderMinusIcon,
FolderPlusIcon,
HashIcon,
ImageIcon,
InfoIcon,
StarIcon,
} from './icons';
import MutedText from './MutedText';
import Type from './Type';
export function FileMeta({ Icon, title, subtitle, ...other }) {
return other.tooltip ? (
<Group>
<Icon size={24} />
<Tooltip label={other.tooltip}>
<Stack spacing={1}>
<Text>{title}</Text>
<MutedText size='md'>{subtitle}</MutedText>
</Stack>
</Tooltip>
</Group>
) : (
<Group>
<Icon size={24} />
<Stack spacing={1}>
<Text>{title}</Text>
<MutedText size='md'>{subtitle}</MutedText>
</Stack>
</Group>
);
}
export default function File({
image,
disableMediaPreview,
exifEnabled,
refreshImages,
reducedActions = false,
}) {
const [open, setOpen] = useState(false);
const [overrideRender, setOverrideRender] = useState(false);
const deleteFile = useFileDelete();
const favoriteFile = useFileFavorite();
const clipboard = useClipboard();
const folders = useFolders();
const loading = deleteFile.isLoading || favoriteFile.isLoading;
const handleDelete = async () => {
deleteFile.mutate(image.id, {
onSuccess: () => {
showNotification({
title: 'File Deleted',
message: '',
color: 'green',
icon: <DeleteIcon />,
});
},
onError: (res: any) => {
showNotification({
title: 'Failed to delete file',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
},
onSettled: () => {
setOpen(false);
},
});
};
const handleCopy = () => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${image.url}`);
setOpen(false);
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
};
const handleFavorite = async () => {
favoriteFile.mutate(
{ id: image.id, favorite: !image.favorite },
{
onSuccess: () => {
showNotification({
title: 'Image is now ' + (!image.favorite ? 'favorited' : 'unfavorited'),
message: '',
icon: <StarIcon />,
});
},
onError: (res: any) => {
showNotification({
title: 'Failed to favorite file',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
},
}
);
};
const inFolder = image.folderId;
const refresh = () => {
refreshImages();
folders.refetch();
};
const removeFromFolder = async () => {
const res = await useFetch('/api/user/folders/' + image.folderId, 'DELETE', {
file: Number(image.id),
});
refresh();
if (!res.error) {
showNotification({
title: 'Removed from folder',
message: res.name,
color: 'green',
icon: <FolderMinusIcon />,
});
} else {
showNotification({
title: 'Failed to remove from folder',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
}
};
const addToFolder = async (t) => {
const res = await useFetch('/api/user/folders/' + t, 'POST', {
file: Number(image.id),
});
refresh();
if (!res.error) {
showNotification({
title: 'Added to folder',
message: res.name,
color: 'green',
icon: <FolderPlusIcon />,
});
} else {
showNotification({
title: 'Failed to add to folder',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
}
};
const createFolder = (t) => {
useFetch('/api/user/folders', 'POST', {
name: t,
add: [Number(image.id)],
}).then((res) => {
refresh();
if (!res.error) {
showNotification({
title: 'Created & added to folder',
message: res.name,
color: 'green',
icon: <FolderPlusIcon />,
});
} else {
showNotification({
title: 'Failed to create folder',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
}
});
return { value: t, label: t };
};
return (
<>
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>{image.name}</Title>} size='xl'>
<LoadingOverlay visible={loading} />
<Stack>
<Type
file={image}
src={`/r/${encodeURI(image.name)}`}
alt={image.name}
popup
sx={{ minHeight: 200 }}
style={{ minHeight: 200 }}
disableMediaPreview={false}
overrideRender={overrideRender}
setOverrideRender={setOverrideRender}
/>
<SimpleGrid
my='md'
cols={3}
breakpoints={[
{ maxWidth: 600, cols: 1 },
{ maxWidth: 900, cols: 2 },
{ maxWidth: 1200, cols: 3 },
]}
>
<FileMeta Icon={FileIcon} title='Name' subtitle={image.name} />
<FileMeta Icon={ImageIcon} title='Type' subtitle={image.mimetype} />
<FileMeta Icon={HardDriveIcon} title='Size' subtitle={bytesToHuman(image.size || 0)} />
<FileMeta Icon={EyeIcon} title='Views' subtitle={image?.views?.toLocaleString()} />
{image.maxViews && (
<FileMeta
Icon={EyeIcon}
title='Max views'
subtitle={image?.maxViews?.toLocaleString()}
tooltip={`This file will be deleted after being viewed ${image?.maxViews?.toLocaleString()} times.`}
/>
)}
<FileMeta
Icon={CalendarIcon}
title='Uploaded'
subtitle={relativeTime(new Date(image.createdAt))}
tooltip={new Date(image?.createdAt).toLocaleString()}
/>
{image.expiresAt && !reducedActions && (
<FileMeta
Icon={ClockIcon}
title='Expires'
subtitle={relativeTime(new Date(image.expiresAt))}
tooltip={new Date(image.expiresAt).toLocaleString()}
/>
)}
<FileMeta Icon={HashIcon} title='ID' subtitle={image.id} />
</SimpleGrid>
</Stack>
<Group position='apart' my='md'>
<Group position='left'>
{exifEnabled && !reducedActions && (
<Tooltip label='View Metadata'>
<ActionIcon
color='blue'
variant='filled'
onClick={() => window.open(`/dashboard/metadata/${image.id}`, '_blank')}
>
<InfoIcon />
</ActionIcon>
</Tooltip>
)}
{reducedActions ? null : inFolder && !folders.isLoading ? (
<Tooltip
label={`Remove from folder "${
folders.data.find((f) => f.id === image.folderId)?.name ?? ''
}"`}
>
<ActionIcon
color='red'
variant='filled'
onClick={removeFromFolder}
loading={folders.isLoading}
>
<FolderMinusIcon />
</ActionIcon>
</Tooltip>
) : (
<Tooltip label='Add to folder'>
<Select
onChange={addToFolder}
placeholder='Add to folder'
data={[
...(folders.data ? folders.data : []).map((folder) => ({
value: String(folder.id),
label: `${folder.id}: ${folder.name}`,
})),
]}
searchable
creatable
getCreateLabel={(query) => `Create folder "${query}"`}
onCreate={createFolder}
/>
</Tooltip>
)}
</Group>
<Group position='right'>
{reducedActions ? null : (
<>
<Tooltip label='Delete file'>
<ActionIcon color='red' variant='filled' onClick={handleDelete}>
<DeleteIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={image.favorite ? 'Unfavorite' : 'Favorite'}>
<ActionIcon
color={image.favorite ? 'yellow' : 'gray'}
variant='filled'
onClick={handleFavorite}
>
<StarIcon />
</ActionIcon>
</Tooltip>
</>
)}
<Tooltip label='Open in new tab'>
<ActionIcon color='blue' variant='filled' onClick={() => window.open(image.url, '_blank')}>
<ExternalLinkIcon />
</ActionIcon>
</Tooltip>
<Tooltip label='Copy URL'>
<ActionIcon color='blue' variant='filled' onClick={handleCopy}>
<CopyIcon />
</ActionIcon>
</Tooltip>
<Tooltip label='Download'>
<ActionIcon
color='blue'
variant='filled'
onClick={() => window.open(`/r/${encodeURI(image.name)}?download=true`, '_blank')}
>
<DownloadIcon />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Modal>
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
<Card.Section>
<LoadingOverlay visible={loading} />
<Type
file={image}
sx={{
minHeight: 200,
maxHeight: 320,
fontSize: 70,
width: '100%',
cursor: 'pointer',
}}
style={{
minHeight: 200,
maxHeight: 320,
fontSize: 70,
width: '100%',
cursor: 'pointer',
}}
src={`/r/${encodeURI(image.name)}`}
alt={image.name}
onClick={() => setOpen(true)}
disableMediaPreview={disableMediaPreview}
/>
</Card.Section>
</Card>
</>
);
}

View file

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

View file

@ -41,6 +41,7 @@ import FolderPlusIcon from './FolderPlusIcon';
import GlobeIcon from './GlobeIcon'; import GlobeIcon from './GlobeIcon';
import LockIcon from './LockIcon'; import LockIcon from './LockIcon';
import UnlockIcon from './UnlockIcon'; import UnlockIcon from './UnlockIcon';
import HardDriveIcon from './HardDriveIcon';
export { export {
ActivityIcon, ActivityIcon,
@ -86,4 +87,5 @@ export {
GlobeIcon, GlobeIcon,
LockIcon, LockIcon,
UnlockIcon, UnlockIcon,
HardDriveIcon,
}; };

View file

@ -299,6 +299,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
expiresAt: expiry, expiresAt: expiry,
maxViews: fileMaxViews, maxViews: fileMaxViews,
originalName: req.headers['original-name'] ? file.originalname ?? null : null, originalName: req.headers['original-name'] ? file.originalname ?? null : null,
size: file.size,
}, },
}); });

View file

@ -82,6 +82,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
expiresAt: Date; expiresAt: Date;
maxViews: number; maxViews: number;
views: number; views: number;
size: number;
}[] = await prisma.file.findMany({ }[] = await prisma.file.findMany({
where: { where: {
userId: user.id, userId: user.id,
@ -99,6 +100,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
favorite: true, favorite: true,
views: true, views: true,
maxViews: true, maxViews: true,
size: true,
}, },
}); });

View file

@ -58,6 +58,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
maxViews: number; maxViews: number;
views: number; views: number;
folderId: number; folderId: number;
size: number;
password: string | boolean; password: string | boolean;
}[] = await prisma.file.findMany({ }[] = await prisma.file.findMany({
where, where,
@ -74,6 +75,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
views: true, views: true,
maxViews: true, maxViews: true,
folderId: true, folderId: true,
size: true,
password: true, password: true,
}, },
skip: page ? (Number(page) - 1) * pageCount : undefined, skip: page ? (Number(page) - 1) * pageCount : undefined,

View file

@ -25,6 +25,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
views: true, views: true,
maxViews: true, maxViews: true,
folderId: true, folderId: true,
size: true,
}, },
}); });

38
src/scripts/query-size.ts Normal file
View file

@ -0,0 +1,38 @@
import { PrismaClient } from '@prisma/client';
import config from 'lib/config';
import datasource from 'lib/datasource';
import { migrations } from 'server/util';
async function main() {
process.env.DATABASE_URL = config.core.database_url;
await migrations();
const prisma = new PrismaClient();
const files = await prisma.file.findMany();
console.log(`The script will attempt to query the size of ${files.length} files.`);
for (let i = 0; i !== files.length; ++i) {
const file = files[i];
const size = await datasource.size(file.name);
if (size === 0) {
console.log(`File ${file.name} has a size of 0 bytes. Ignoring...`);
} else {
console.log(`File ${file.name} has a size of ${size} bytes. Updating...`);
await prisma.file.update({
where: {
id: file.id,
},
data: {
size,
},
});
}
}
console.log('Done.');
process.exit(0);
}
main();

View file

@ -25,10 +25,6 @@
}, },
"incremental": true "incremental": true
}, },
"include": [ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
],
"exclude": ["node_modules", "dist", ".yarn", ".next"] "exclude": ["node_modules", "dist", ".yarn", ".next"]
} }

View file

@ -39,4 +39,9 @@ export default defineConfig([
outDir: 'dist/scripts', outDir: 'dist/scripts',
...opts, ...opts,
}, },
{
entryPoints: ['src/scripts/query-size.ts'],
outDir: 'dist/scripts',
...opts,
},
]); ]);