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 {
id Int @id @default(autoincrement())
name String
public Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View file

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

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 FolderPlusIcon from './FolderPlusIcon';
import GlobeIcon from './GlobeIcon';
import LockIcon from './LockIcon';
import UnlockIcon from './UnlockIcon';
export {
ActivityIcon,
@ -82,4 +84,6 @@ export {
FolderMinusIcon,
FolderPlusIcon,
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 { useModals } from '@mantine/modals';
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 useFetch from 'hooks/useFetch';
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 (
<>
<CreateFolderModal
@ -101,6 +126,7 @@ export default function Folders({ disableMediaPreview, exifEnabled }) {
<Stack spacing={0}>
<Title>{folder.name}</Title>
<MutedText size='sm'>ID: {folder.id}</MutedText>
<MutedText size='sm'>Public: {folder.public ? 'Yes' : 'No'}</MutedText>
<Tooltip label={new Date(folder.createdAt).toLocaleString()}>
<div>
<MutedText size='sm'>
@ -117,7 +143,15 @@ export default function Folders({ disableMediaPreview, exifEnabled }) {
</Tooltip>
</Stack>
</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
aria-label='view files'
onClick={() => {
@ -127,10 +161,28 @@ export default function Folders({ disableMediaPreview, exifEnabled }) {
>
<FileIcon />
</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)}>
<DeleteIcon />
</ActionIcon>
</Stack>
</Group>
</Group>
</Card>
))

View file

@ -8,6 +8,7 @@ export type UserFoldersResponse = {
userId: number;
createdAt: string;
updatedAt: string;
public: boolean;
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() {
return queryClient.invalidateQueries(['folders']);
}

View file

@ -22,6 +22,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
userId: true,
createdAt: true,
updatedAt: true,
public: true,
},
});
@ -75,6 +76,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
userId: true,
createdAt: 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);
} else if (req.method === 'DELETE') {
const deletingFolder = !!req.body.deleteFolder;
@ -111,6 +147,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
userId: true,
createdAt: true,
updatedAt: true,
public: true,
},
});
@ -167,6 +204,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
userId: true,
createdAt: true,
updatedAt: true,
public: true,
},
});
@ -208,6 +246,6 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
}
export default withZipline(handler, {
methods: ['GET', 'POST', 'DELETE'],
methods: ['GET', 'POST', 'DELETE', 'PATCH'],
user: true,
});

View file

@ -69,6 +69,10 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
userId: true,
createdAt: 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,
},
};
};