feat: public folders

This commit is contained in:
diced 2023-02-23 14:37:22 -08:00
parent 6955d83b0c
commit fc02dc02e8
No known key found for this signature in database
GPG key ID: 370BD1BA142842D1
11 changed files with 255 additions and 71 deletions

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Folder" ADD COLUMN "public" BOOLEAN NOT NULL DEFAULT false;

View file

@ -30,6 +30,7 @@ model User {
model Folder { model Folder {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
public Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View file

@ -4,7 +4,6 @@ import {
Group, Group,
LoadingOverlay, LoadingOverlay,
Modal, Modal,
Paper,
Select, Select,
SimpleGrid, SimpleGrid,
Stack, Stack,
@ -15,10 +14,9 @@ import {
import { useClipboard } from '@mantine/hooks'; import { useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import useFetch from 'hooks/useFetch'; import useFetch from 'hooks/useFetch';
import { invalidateFiles, useFileDelete, useFileFavorite } from 'lib/queries/files'; import { useFileDelete, useFileFavorite } from 'lib/queries/files';
import { invalidateFolders, useFolders } from 'lib/queries/folders'; import { useFolders } from 'lib/queries/folders';
import { relativeTime } from 'lib/utils/client'; import { relativeTime } from 'lib/utils/client';
import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { import {
CalendarIcon, CalendarIcon,
@ -30,12 +28,12 @@ import {
ExternalLinkIcon, ExternalLinkIcon,
EyeIcon, EyeIcon,
FileIcon, FileIcon,
HashIcon,
ImageIcon,
StarIcon,
InfoIcon,
FolderMinusIcon, FolderMinusIcon,
FolderPlusIcon, FolderPlusIcon,
HashIcon,
ImageIcon,
InfoIcon,
StarIcon,
} from './icons'; } from './icons';
import MutedText from './MutedText'; import MutedText from './MutedText';
import Type from './Type'; import Type from './Type';
@ -62,13 +60,18 @@ export function FileMeta({ Icon, title, subtitle, ...other }) {
); );
} }
export default function File({ image, disableMediaPreview, exifEnabled, refreshImages }) { export default function File({
image,
disableMediaPreview,
exifEnabled,
refreshImages,
reducedActions = false,
}) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [overrideRender, setOverrideRender] = useState(false); const [overrideRender, setOverrideRender] = useState(false);
const deleteFile = useFileDelete(); const deleteFile = useFileDelete();
const favoriteFile = useFileFavorite(); const favoriteFile = useFileFavorite();
const clipboard = useClipboard(); const clipboard = useClipboard();
const router = useRouter();
const folders = useFolders(); const folders = useFolders();
@ -257,7 +260,7 @@ export default function File({ image, disableMediaPreview, exifEnabled, refreshI
subtitle={relativeTime(new Date(image.createdAt))} subtitle={relativeTime(new Date(image.createdAt))}
tooltip={new Date(image?.createdAt).toLocaleString()} tooltip={new Date(image?.createdAt).toLocaleString()}
/> />
{image.expiresAt && ( {image.expiresAt && !reducedActions && (
<FileMeta <FileMeta
Icon={ClockIcon} Icon={ClockIcon}
title='Expires' title='Expires'
@ -271,7 +274,7 @@ export default function File({ image, disableMediaPreview, exifEnabled, refreshI
<Group position='apart' my='md'> <Group position='apart' my='md'>
<Group position='left'> <Group position='left'>
{exifEnabled && ( {exifEnabled && !reducedActions && (
<Tooltip label='View Metadata'> <Tooltip label='View Metadata'>
<ActionIcon <ActionIcon
color='blue' color='blue'
@ -282,7 +285,7 @@ export default function File({ image, disableMediaPreview, exifEnabled, refreshI
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
{inFolder && !folders.isLoading ? ( {reducedActions ? null : inFolder && !folders.isLoading ? (
<Tooltip <Tooltip
label={`Remove from folder "${ label={`Remove from folder "${
folders.data.find((f) => f.id === image.folderId)?.name ?? '' folders.data.find((f) => f.id === image.folderId)?.name ?? ''
@ -317,6 +320,8 @@ export default function File({ image, disableMediaPreview, exifEnabled, refreshI
)} )}
</Group> </Group>
<Group position='right'> <Group position='right'>
{reducedActions ? null : (
<>
<Tooltip label='Delete file'> <Tooltip label='Delete file'>
<ActionIcon color='red' variant='filled' onClick={handleDelete}> <ActionIcon color='red' variant='filled' onClick={handleDelete}>
<DeleteIcon /> <DeleteIcon />
@ -332,6 +337,8 @@ export default function File({ image, disableMediaPreview, exifEnabled, refreshI
<StarIcon /> <StarIcon />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</>
)}
<Tooltip label='Open in new tab'> <Tooltip label='Open in new tab'>
<ActionIcon color='blue' variant='filled' onClick={() => window.open(image.url, '_blank')}> <ActionIcon color='blue' variant='filled' onClick={() => window.open(image.url, '_blank')}>

View file

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

View file

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

View file

@ -39,6 +39,8 @@ import FolderIcon from './FolderIcon';
import FolderMinusIcon from './FolderMinusIcon'; import FolderMinusIcon from './FolderMinusIcon';
import FolderPlusIcon from './FolderPlusIcon'; import FolderPlusIcon from './FolderPlusIcon';
import GlobeIcon from './GlobeIcon'; import GlobeIcon from './GlobeIcon';
import LockIcon from './LockIcon';
import UnlockIcon from './UnlockIcon';
export { export {
ActivityIcon, ActivityIcon,
@ -82,4 +84,6 @@ export {
FolderMinusIcon, FolderMinusIcon,
FolderPlusIcon, FolderPlusIcon,
GlobeIcon, GlobeIcon,
LockIcon,
UnlockIcon,
}; };

View file

@ -2,7 +2,8 @@ import { ActionIcon, Avatar, Card, Group, SimpleGrid, Skeleton, Stack, Title, To
import { useClipboard } from '@mantine/hooks'; import { useClipboard } from '@mantine/hooks';
import { useModals } from '@mantine/modals'; import { useModals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import { CopyIcon, DeleteIcon, FileIcon, PlusIcon } from 'components/icons'; import { DeleteIcon, FileIcon, PlusIcon, LockIcon, UnlockIcon, LinkIcon, CopyIcon } from 'components/icons';
import Link from 'components/Link';
import MutedText from 'components/MutedText'; import MutedText from 'components/MutedText';
import useFetch from 'hooks/useFetch'; import useFetch from 'hooks/useFetch';
import { useFolders } from 'lib/queries/folders'; import { useFolders } from 'lib/queries/folders';
@ -65,6 +66,30 @@ export default function Folders({ disableMediaPreview, exifEnabled }) {
}); });
}; };
const makePublic = async (folder) => {
const res = await useFetch(`/api/user/folders/${folder.id}`, 'PATCH', {
public: folder.public ? false : true,
});
if (!res.error) {
showNotification({
title: 'Made folder public',
message: `Made folder ${folder.name} ${folder.public ? 'private' : 'public'}`,
color: 'green',
icon: <UnlockIcon />,
});
folders.refetch();
} else {
showNotification({
title: 'Failed to make folder public/private',
message: res.error,
color: 'red',
icon: <UnlockIcon />,
});
folders.refetch();
}
};
return ( return (
<> <>
<CreateFolderModal <CreateFolderModal
@ -101,6 +126,7 @@ export default function Folders({ disableMediaPreview, exifEnabled }) {
<Stack spacing={0}> <Stack spacing={0}>
<Title>{folder.name}</Title> <Title>{folder.name}</Title>
<MutedText size='sm'>ID: {folder.id}</MutedText> <MutedText size='sm'>ID: {folder.id}</MutedText>
<MutedText size='sm'>Public: {folder.public ? 'Yes' : 'No'}</MutedText>
<Tooltip label={new Date(folder.createdAt).toLocaleString()}> <Tooltip label={new Date(folder.createdAt).toLocaleString()}>
<div> <div>
<MutedText size='sm'> <MutedText size='sm'>
@ -117,7 +143,15 @@ export default function Folders({ disableMediaPreview, exifEnabled }) {
</Tooltip> </Tooltip>
</Stack> </Stack>
</Group> </Group>
<Stack> <Group>
<Tooltip label={folder.public ? 'Make folder private' : 'Make folder public'}>
<ActionIcon
aria-label={folder.public ? 'make private' : 'make public'}
onClick={() => makePublic(folder)}
>
{folder.public ? <LockIcon /> : <UnlockIcon />}
</ActionIcon>
</Tooltip>
<ActionIcon <ActionIcon
aria-label='view files' aria-label='view files'
onClick={() => { onClick={() => {
@ -127,10 +161,28 @@ export default function Folders({ disableMediaPreview, exifEnabled }) {
> >
<FileIcon /> <FileIcon />
</ActionIcon> </ActionIcon>
<ActionIcon
aria-label='copy link'
onClick={() => {
clipboard.copy(`${window.location.origin}/folder/${folder.id}`);
showNotification({
title: 'Copied folder link',
message: (
<>
Copied <Link href={`/folder/${folder.id}`}>folder link</Link> to clipboard
</>
),
color: 'green',
icon: <CopyIcon />,
});
}}
>
<LinkIcon />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => deleteFolder(folder)}> <ActionIcon aria-label='delete' onClick={() => deleteFolder(folder)}>
<DeleteIcon /> <DeleteIcon />
</ActionIcon> </ActionIcon>
</Stack> </Group>
</Group> </Group>
</Card> </Card>
)) ))

View file

@ -8,6 +8,7 @@ export type UserFoldersResponse = {
userId: number; userId: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
public: boolean;
files?: UserFilesResponse[]; files?: UserFilesResponse[];
}; };
@ -40,46 +41,6 @@ export const useFolder = (id: string, withFiles: boolean = false) => {
}); });
}; };
// 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 invalidateFolders() { export function invalidateFolders() {
return queryClient.invalidateQueries(['folders']); return queryClient.invalidateQueries(['folders']);
} }

View file

@ -22,6 +22,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
userId: true, userId: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
public: true,
}, },
}); });
@ -75,6 +76,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
userId: true, userId: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
public: true,
}, },
}); });
@ -96,6 +98,40 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
} }
} }
return res.json(folder);
} else if (req.method === 'PATCH') {
const { public: publicFolder } = req.body as { public?: string };
const folder = await prisma.folder.update({
where: {
id: idParsed,
},
data: {
public: !!publicFolder,
},
select: {
files: !!req.query.files,
id: true,
name: true,
userId: true,
createdAt: true,
updatedAt: true,
public: true,
},
});
if (req.query.files) {
for (let i = 0; i !== folder.files.length; ++i) {
const file = folder.files[i];
delete file.password;
(folder.files[i] as unknown as { url: string }).url = formatRootUrl(
config.uploader.route,
folder.files[i].name
);
}
}
return res.json(folder); return res.json(folder);
} else if (req.method === 'DELETE') { } else if (req.method === 'DELETE') {
const deletingFolder = !!req.body.deleteFolder; const deletingFolder = !!req.body.deleteFolder;
@ -111,6 +147,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
userId: true, userId: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
public: true,
}, },
}); });
@ -167,6 +204,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
userId: true, userId: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
public: true,
}, },
}); });
@ -208,6 +246,6 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
} }
export default withZipline(handler, { export default withZipline(handler, {
methods: ['GET', 'POST', 'DELETE'], methods: ['GET', 'POST', 'DELETE', 'PATCH'],
user: true, user: true,
}); });

View file

@ -69,6 +69,10 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
userId: true, userId: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
public: true,
},
orderBy: {
updatedAt: 'desc',
}, },
}); });

105
src/pages/folder/[id].tsx Normal file
View file

@ -0,0 +1,105 @@
import { Container, SimpleGrid, Title } from '@mantine/core';
import File from 'components/File';
import prisma from 'lib/prisma';
import { formatRootUrl } from 'lib/utils/urls';
import { GetServerSideProps } from 'next';
type LimitedFolder = {
files: {
id: number;
name: string;
createdAt: Date | string;
mimetype: string;
views: number;
}[];
user: {
username: string;
};
name: string;
public: boolean;
url?: string;
};
type Props = {
folder: LimitedFolder;
uploadRoute: string;
};
export default function EmbeddedFile({ folder }: Props) {
return (
<Container size='lg'>
<Title align='center' my='lg'>
Viewing folder: {folder.name}
</Title>
<SimpleGrid
my='md'
cols={3}
breakpoints={[
{ maxWidth: 600, cols: 1 },
{ maxWidth: 900, cols: 2 },
{ maxWidth: 1200, cols: 3 },
]}
>
{folder.files.map((file, i) => (
<File
key={i}
image={file}
disableMediaPreview={false}
exifEnabled={false}
refreshImages={null}
reducedActions={true}
/>
))}
</SimpleGrid>
</Container>
);
}
export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
const { id } = context.params as { id: string };
if (isNaN(Number(id))) return { notFound: true };
const folder = await prisma.folder.findFirst({
where: {
id: Number(id),
},
select: {
files: {
select: {
name: true,
mimetype: true,
id: true,
views: true,
createdAt: true,
},
},
user: {
select: {
username: true,
},
},
name: true,
public: true,
},
});
if (!folder) return { notFound: true };
if (!folder.public) return { notFound: true };
for (let j = 0; j !== folder.files.length; ++j) {
(folder.files[j] as unknown as { url: string }).url = formatRootUrl(
config.uploader.route,
folder.files[j].name
);
(folder.files[j].createdAt as unknown) = folder.files[j].createdAt.toString();
}
return {
props: {
folder,
uploadRoute: config.uploader.route,
},
};
};