feat(v3.7.0-rc3): folders for files

This commit is contained in:
diced 2023-02-10 22:32:57 -08:00
parent 06e84b41aa
commit fb5f50d5bd
No known key found for this signature in database
GPG key ID: 370BD1BA142842D1
24 changed files with 907 additions and 67 deletions

View file

@ -1,6 +1,6 @@
{
"name": "zipline",
"version": "3.7.0-rc2",
"version": "3.7.0-rc3",
"license": "MIT",
"scripts": {
"dev": "npm-run-all build:server dev:run",

View file

@ -0,0 +1,19 @@
-- AlterTable
ALTER TABLE "File" ADD COLUMN "folderId" INTEGER;
-- CreateTable
CREATE TABLE "Folder" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "Folder_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "File" ADD CONSTRAINT "File_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -21,9 +21,22 @@ model User {
totpSecret String?
domains String[]
oauth OAuth[]
files File[]
files File[]
urls Url[]
Invite Invite[]
Folder Folder[]
}
model Folder {
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
files File[]
}
enum FileNameFormat {
@ -34,30 +47,33 @@ enum FileNameFormat {
}
model File {
id Int @id @default(autoincrement())
name String
id Int @id @default(autoincrement())
name String
originalName String?
mimetype String @default("image/png")
createdAt DateTime @default(now())
expiresAt DateTime?
maxViews Int?
views Int @default(0)
favorite Boolean @default(false)
embed Boolean @default(false)
password String?
invisible InvisibleFile?
format FileNameFormat @default(RANDOM)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId Int?
mimetype String @default("image/png")
createdAt DateTime @default(now())
expiresAt DateTime?
maxViews Int?
views Int @default(0)
favorite Boolean @default(false)
embed Boolean @default(false)
password String?
invisible InvisibleFile?
format FileNameFormat @default(RANDOM)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId Int?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
folderId Int?
}
model InvisibleFile {
id Int @id @default(autoincrement())
invis String @unique
// imageId Int @unique
fileId Int @unique
// image File @relation(fields: [imageId], references: [id], onDelete: Cascade)
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
invis String @unique
fileId Int @unique
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
}
model Url {
@ -68,30 +84,33 @@ model Url {
maxViews Int?
views Int @default(0)
invisible InvisibleUrl?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId Int?
}
model InvisibleUrl {
id Int @id @default(autoincrement())
invis String @unique
urlId String @unique
url Url @relation(fields: [urlId], references: [id], onDelete: Cascade)
}
model Stats {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
data Json
data Json
}
model Invite {
id Int @id @default(autoincrement())
code String @unique
createdAt DateTime @default(now())
expiresAt DateTime?
used Boolean @default(false)
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
code String @unique
createdAt DateTime @default(now())
expiresAt DateTime?
used Boolean @default(false)
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
createdById Int
}

View file

@ -5,6 +5,7 @@ import {
LoadingOverlay,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Text,
@ -13,8 +14,11 @@ import {
} from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
import useFetch from 'hooks/useFetch';
import { invalidateFiles, useFileDelete, useFileFavorite } from 'lib/queries/files';
import { invalidateFolders, useFolders } from 'lib/queries/folders';
import { relativeTime } from 'lib/utils/client';
import { useRouter } from 'next/router';
import { useState } from 'react';
import {
CalendarIcon,
@ -30,10 +34,17 @@ import {
ImageIcon,
StarIcon,
InfoIcon,
FolderMinusIcon,
FolderPlusIcon,
} from './icons';
import MutedText from './MutedText';
import Type from './Type';
const CREATE_FOLDER_OPTION = {
label: 'Create new folder',
value: 'create',
};
export function FileMeta({ Icon, title, subtitle, ...other }) {
return other.tooltip ? (
<Group>
@ -56,12 +67,15 @@ export function FileMeta({ Icon, title, subtitle, ...other }) {
);
}
export default function File({ image, disableMediaPreview, exifEnabled }) {
export default function File({ image, disableMediaPreview, exifEnabled, refreshImages }) {
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();
const loading = deleteFile.isLoading || favoriteFile.isLoading;
@ -125,6 +139,65 @@ export default function File({ image, disableMediaPreview, exifEnabled }) {
);
};
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) => {
if (t === CREATE_FOLDER_OPTION.value) {
router.push('/dashboard/folders?create=' + image.id);
} else {
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 />,
});
}
}
};
return (
<>
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>{image.name}</Title>} size='xl'>
@ -182,9 +255,6 @@ export default function File({ image, disableMediaPreview, exifEnabled }) {
<Group position='apart' my='md'>
<Group position='left'>
{exifEnabled && (
// <Link href={`/dashboard/metadata/${image.id}`} target='_blank' rel='noopener noreferrer'>
// <Button leftIcon={<ExternalLinkIcon />}>View Metadata</Button>
// </Link>
<Tooltip label='View Metadata'>
<ActionIcon
color='blue'
@ -195,6 +265,36 @@ export default function File({ image, disableMediaPreview, exifEnabled }) {
</ActionIcon>
</Tooltip>
)}
{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}`,
})),
CREATE_FOLDER_OPTION,
]}
/>
</Tooltip>
)}
</Group>
<Group position='right'>
<Tooltip label='Delete file'>
@ -235,17 +335,6 @@ export default function File({ image, disableMediaPreview, exifEnabled }) {
</ActionIcon>
</Tooltip>
</Group>
{/* {exifEnabled && (
<Link href={`/dashboard/metadata/${image.id}`} target='_blank' rel='noopener noreferrer'>
<Button leftIcon={<ExternalLinkIcon />}>View Metadata</Button>
</Link>
)}
<Button onClick={handleCopy}>Copy URL</Button>
<Button onClick={handleDelete}>Delete</Button>
<Button onClick={handleFavorite}>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
<Link href={image.url} target='_blank'>
<Button rightIcon={<ExternalLinkIcon />}>Open</Button>
</Link> */}
</Group>
</Modal>
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>

View file

@ -38,6 +38,7 @@ import {
DiscordIcon,
ExternalLinkIcon,
FileIcon,
FolderIcon,
GitHubIcon,
GoogleIcon,
HomeIcon,
@ -71,6 +72,11 @@ const items: NavbarItems[] = [
text: 'Files',
link: '/dashboard/files',
},
{
icon: <FolderIcon size={18} />,
text: 'Folders',
link: '/dashboard/folders',
},
{
icon: <ActivityIcon size={18} />,
text: 'Stats',

View file

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

View file

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

View file

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

View file

@ -35,6 +35,9 @@ import RefreshIcon from './RefreshIcon';
import KeyIcon from './KeyIcon';
import DatabaseIcon from './DatabaseIcon';
import InfoIcon from './InfoIcon';
import FolderIcon from './FolderIcon';
import FolderMinusIcon from './FolderMinusIcon';
import FolderPlusIcon from './FolderPlusIcon';
export {
ActivityIcon,
@ -74,4 +77,7 @@ export {
KeyIcon,
DatabaseIcon,
InfoIcon,
FolderIcon,
FolderMinusIcon,
FolderPlusIcon,
};

View file

@ -24,6 +24,7 @@ export default function RecentFiles({ disableMediaPreview, exifEnabled }) {
image={image}
disableMediaPreview={disableMediaPreview}
exifEnabled={exifEnabled}
refreshImages={recent.refetch}
/>
))
) : (

View file

@ -67,7 +67,12 @@ export default function FilePagation({ disableMediaPreview, exifEnabled, queryPa
? pages.data.length
? pages.data.map((image) => (
<div key={image.id}>
<File image={image} disableMediaPreview={disableMediaPreview} exifEnabled={exifEnabled} />
<File
image={image}
disableMediaPreview={disableMediaPreview}
exifEnabled={exifEnabled}
refreshImages={pages.refetch}
/>
</div>
))
: null

View file

@ -19,12 +19,6 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage }) {
})();
});
const updatePages = async (favorite) => {
if (favorite) {
favoritePages.refetch();
}
};
return (
<>
<Group mb='md'>
@ -55,6 +49,7 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage }) {
image={image}
disableMediaPreview={disableMediaPreview}
exifEnabled={exifEnabled}
refreshImages={favoritePages.refetch}
/>
</div>
))

View file

@ -0,0 +1,66 @@
import { Button, Group, Modal, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { showNotification } from '@mantine/notifications';
import { CrossIcon, FolderIcon } from 'components/icons';
import MutedText from 'components/MutedText';
import useFetch from 'hooks/useFetch';
import { useRouter } from 'next/router';
export default function CreateFolderModal({ open, setOpen, updateFolders, createWithFile }) {
const router = useRouter();
const form = useForm({
initialValues: {
name: '',
},
});
const onSubmit = async (values) => {
const res = await useFetch('/api/user/folders', 'POST', {
name: values.name,
add: createWithFile ? [createWithFile] : undefined,
});
if (res.error) {
showNotification({
title: 'Failed to create folder',
message: res.error,
icon: <CrossIcon />,
color: 'red',
});
} else {
showNotification({
title: 'Created folder ' + res.name,
message: createWithFile ? 'Added file to folder' : undefined,
icon: <FolderIcon />,
color: 'green',
});
if (createWithFile) {
router.push('/dashboard/folders');
}
}
setOpen(false);
updateFolders();
};
return (
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>Create Folder</Title>}>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput label='Folder Name' placeholder='Folder Name' {...form.getInputProps('name')} />
{createWithFile && (
<MutedText size='sm'>
Creating this folder will add file with an ID of <b>{createWithFile}</b> to it automatically.
</MutedText>
)}
<Group position='right' mt='md'>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button type='submit'>Create</Button>
</Group>
</form>
</Modal>
);
}

View file

@ -0,0 +1,46 @@
import { Center, Modal, SimpleGrid, Stack, Text, Title } from '@mantine/core';
import File from 'components/File';
import MutedText from 'components/MutedText';
import { useFolder } from 'lib/queries/folders';
export default function ViewFolderFilesModal({ open, setOpen, folderId, disableMediaPreview, exifEnabled }) {
if (!folderId) return null;
const folder = useFolder(folderId, true);
return (
<Modal
opened={open}
onClose={() => setOpen(false)}
title={<Title>View {folder.data?.name}&apos;s files</Title>}
size='xl'
>
{folder.isSuccess ? (
<>
{folder.data.files.length ? (
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{folder.data.files.map((file) => (
<File
disableMediaPreview={disableMediaPreview}
key={file.id}
image={file}
exifEnabled={exifEnabled}
refreshImages={folder.refetch}
/>
))}
</SimpleGrid>
) : (
<Center>
<Stack>
<Text align='center'>No files in this folder</Text>
<MutedText size='sm'>
Add files to {folder.data.name} by clicking a file in the Files tab and selecting a folder
</MutedText>
</Stack>
</Center>
)}
</>
) : null}
</Modal>
);
}

View file

@ -0,0 +1,146 @@
import { ActionIcon, Avatar, Card, Group, SimpleGrid, Skeleton, Stack, Title, Tooltip } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { CopyIcon, DeleteIcon, FileIcon, PlusIcon } from 'components/icons';
import MutedText from 'components/MutedText';
import useFetch from 'hooks/useFetch';
import { useFolders } from 'lib/queries/folders';
import { relativeTime } from 'lib/utils/client';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import CreateFolderModal from './CreateFolderModal';
import ViewFolderFilesModal from './ViewFolderFilesModal';
export default function Folders({ disableMediaPreview, exifEnabled }) {
const folders = useFolders();
const [createOpen, setCreateOpen] = useState(false);
const [createWithFile, setCreateWithFile] = useState(null);
const [viewOpen, setViewOpen] = useState(false);
const [activeFolderId, setActiveFolderId] = useState(null);
const modals = useModals();
const clipboard = useClipboard();
const router = useRouter();
useEffect(() => {
if (router.query.create) {
setCreateOpen(true);
setCreateWithFile(router.query.create);
}
}, []);
const deleteFolder = (folder) => {
modals.openConfirmModal({
title: <Title>Delete folder {folder.name}?</Title>,
children:
'Are you sure you want to delete this folder? All files within the folder will still exist, but will no longer be in a folder.',
labels: {
confirm: 'Delete',
cancel: 'Cancel',
},
onConfirm: async () => {
const res = await useFetch(`/api/user/folders/${folder.id}`, 'DELETE', {
deleteFolder: true,
});
if (!res.error) {
showNotification({
title: 'Deleted folder',
message: `Deleted folder ${folder.name}`,
color: 'green',
icon: <DeleteIcon />,
});
folders.refetch();
} else {
showNotification({
title: 'Failed to delete folder',
message: res.error,
color: 'red',
icon: <DeleteIcon />,
});
folders.refetch();
}
},
});
};
return (
<>
<CreateFolderModal
open={createOpen}
setOpen={setCreateOpen}
createWithFile={createWithFile}
updateFolders={folders.refetch}
/>
<ViewFolderFilesModal
open={viewOpen}
setOpen={setViewOpen}
folderId={activeFolderId}
disableMediaPreview={disableMediaPreview}
exifEnabled={exifEnabled}
/>
<Group mb='md'>
<Title>Folders</Title>
<ActionIcon onClick={() => setCreateOpen(!createOpen)} component='a' variant='filled' color='primary'>
<PlusIcon />
</ActionIcon>
</Group>
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{folders.isSuccess
? folders.data.length
? folders.data.map((folder) => (
<Card key={folder.id}>
<Group position='apart'>
<Group position='left'>
<Avatar size='lg' color='primary'>
{folder.id}
</Avatar>
<Stack spacing={0}>
<Title>{folder.name}</Title>
<MutedText size='sm'>ID: {folder.id}</MutedText>
<Tooltip label={new Date(folder.createdAt).toLocaleString()}>
<div>
<MutedText size='sm'>
Created {relativeTime(new Date(folder.createdAt))}
</MutedText>
</div>
</Tooltip>
<Tooltip label={new Date(folder.updatedAt).toLocaleString()}>
<div>
<MutedText size='sm'>
Last updated {relativeTime(new Date(folder.updatedAt))}
</MutedText>
</div>
</Tooltip>
</Stack>
</Group>
<Stack>
<ActionIcon
aria-label='view files'
onClick={() => {
setViewOpen(!viewOpen);
setActiveFolderId(folder.id);
}}
>
<FileIcon />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => deleteFolder(folder)}>
<DeleteIcon />
</ActionIcon>
</Stack>
</Group>
</Card>
))
: null
: [1, 2, 3, 4].map((x) => (
<div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
</div>
))}
</SimpleGrid>
</>
);
}

View file

@ -0,0 +1,85 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import queryClient from './client';
import { UserFilesResponse } from './files';
export type UserFoldersResponse = {
id: number;
name: string;
userId: number;
createdAt: string;
updatedAt: string;
files?: UserFilesResponse[];
};
export const useFolders = (query: { [key: string]: string } = {}) => {
const queryBuilder = new URLSearchParams(query);
const queryString = queryBuilder.toString();
return useQuery<UserFoldersResponse[]>(['folders', queryString], async () => {
return fetch('/api/user/folders?' + queryString)
.then((res) => res.json() as Promise<UserFoldersResponse[]>)
.then((data) =>
data.map((x) => ({
...x,
createdAt: new Date(x.createdAt).toLocaleString(),
updatedAt: new Date(x.updatedAt).toLocaleString(),
}))
);
});
};
export const useFolder = (id: string, withFiles: boolean = false) => {
return useQuery<UserFoldersResponse>(['folder', id], async () => {
return fetch('/api/user/folders/' + id + (withFiles ? '?files=true' : ''))
.then((res) => res.json() as Promise<UserFoldersResponse>)
.then((data) => ({
...data,
createdAt: new Date(data.createdAt).toLocaleString(),
updatedAt: new Date(data.updatedAt).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 invalidateFolders() {
return queryClient.invalidateQueries(['folders']);
}

3
src/lib/utils/urls.ts Normal file
View file

@ -0,0 +1,3 @@
export function formatRootUrl(route: string, src: string) {
return `${route === '/' ? '' : route}/${src}`;
}

View file

@ -2,6 +2,7 @@ import config from 'lib/config';
import datasource from 'lib/datasource';
import Logger from 'lib/logger';
import prisma from 'lib/prisma';
import { formatRootUrl } from 'lib/utils/urls';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
const logger = Logger.get('files');
@ -89,7 +90,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
});
for (let i = 0; i !== files.length; ++i) {
(files[i] as unknown as { url: string }).url = `${config.uploader.route}/${files[i].name}`;
(files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
}
if (req.query.filter && req.query.filter === 'media')

View file

@ -0,0 +1,213 @@
import Logger from 'lib/logger';
import prisma from 'lib/prisma';
import { formatRootUrl } from 'lib/utils/urls';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
const logger = Logger.get('folders');
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const { id } = req.query as { id: string };
const idParsed = Number(id);
if (isNaN(idParsed)) return res.badRequest('id must be a number');
const folder = await prisma.folder.findUnique({
where: {
id: idParsed,
},
select: {
files: !!req.query.files,
id: true,
name: true,
userId: true,
createdAt: true,
updatedAt: true,
},
});
if (!folder) return res.notFound('folder not found');
if (folder.userId !== user.id) return res.forbidden('you do not have permission to access this folder');
if (req.method === 'POST') {
const { file: fileId }: { file: string } = req.body;
if (!fileId) return res.badRequest('file is required');
const fileIdParsed = Number(fileId);
if (isNaN(fileIdParsed)) return res.badRequest('file must be a number');
const file = await prisma.file.findUnique({
where: {
id: fileIdParsed,
},
});
if (!file) return res.notFound('file not found');
if (file.userId !== user.id) return res.forbidden('you do not have permission to access this file');
const fileInFolder = await prisma.file.findFirst({
where: {
id: fileIdParsed,
folder: {
id: idParsed,
},
},
});
if (fileInFolder) return res.badRequest('file is already in folder');
const folder = await prisma.folder.update({
where: {
id: idParsed,
},
data: {
files: {
connect: {
id: fileIdParsed,
},
},
},
select: {
files: !!req.query.files,
id: true,
name: true,
userId: true,
createdAt: true,
updatedAt: true,
},
});
logger.debug(`added file ${fileIdParsed} to folder ${idParsed}`);
logger.info(
`Added file "${file.name}" to folder "${folder.name}" for user ${user.username} (${user.id})`
);
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;
if (deletingFolder) {
const folder = await prisma.folder.delete({
where: {
id: idParsed,
},
select: {
id: true,
name: true,
userId: true,
createdAt: true,
updatedAt: true,
},
});
logger.debug(`deleted folder ${idParsed}`);
logger.info(`Deleted folder "${folder.name}" for user ${user.username} (${user.id})`);
return res.json(folder);
} else {
const { file: fileId }: { file: string } = req.body;
if (!fileId) return res.badRequest('file is required');
const fileIdParsed = Number(fileId);
if (isNaN(fileIdParsed)) return res.badRequest('file must be a number');
const file = await prisma.file.findUnique({
where: {
id: fileIdParsed,
},
});
if (!file) return res.notFound('file not found');
if (file.userId !== user.id) return res.forbidden('you do not have permission to access this file');
const fileInFolder = await prisma.file.findFirst({
where: {
id: fileIdParsed,
folder: {
id: idParsed,
},
},
});
if (!fileInFolder) return res.badRequest('file is not in folder');
const folder = await prisma.folder.update({
where: {
id: idParsed,
},
data: {
files: {
disconnect: {
id: fileIdParsed,
},
},
},
select: {
files: !!req.query.files,
id: true,
name: true,
userId: true,
createdAt: true,
updatedAt: true,
},
});
logger.debug(`removed file ${fileIdParsed} from folder ${idParsed}`);
logger.info(
`Removed file "${file.name}" from folder "${folder.name}" for user ${user.username} (${user.id})`
);
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.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);
}
}
export default withZipline(handler, {
methods: ['GET', 'POST', 'DELETE'],
user: true,
});

View file

@ -0,0 +1,97 @@
import config from 'lib/config';
import Logger from 'lib/logger';
import prisma from 'lib/prisma';
import { formatRootUrl } from 'lib/utils/urls';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
const logger = Logger.get('folders');
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (req.method === 'POST') {
const { name, add }: { name: string; add: string[] } = req.body;
if (!name) return res.badRequest('name is required');
if (name.trim() === '') return res.badRequest('name cannot be empty');
if (add) {
// add contains a list of file IDs to add to the folder
const files = await prisma.file.findMany({
where: {
id: {
in: add.map((id) => Number(id)),
},
userId: user.id,
},
});
if (files.length !== add.length)
return res.badRequest(
`files ${add.filter((id) => !files.find((file) => file.id === Number(id))).join(', ')} not found`
);
const folder = await prisma.folder.create({
data: {
name,
userId: user.id,
files: {
connect: files.map((file) => ({ id: file.id })),
},
},
});
logger.debug(`created folder ${JSON.stringify(folder)}`);
logger.info(`Created folder "${folder.name}" for user ${user.username} (${user.id})`);
return res.json(folder);
}
const folder = await prisma.folder.create({
data: {
name,
userId: user.id,
},
});
logger.debug(`created folder ${JSON.stringify(folder)}`);
logger.info(`Created folder "${folder.name}" for user ${user.username} (${user.id})`);
return res.json(folder);
} else {
const folders = await prisma.folder.findMany({
where: {
userId: user.id,
},
select: {
files: !!req.query.files,
id: true,
name: true,
userId: true,
createdAt: true,
updatedAt: true,
},
});
if (req.query.files) {
for (let i = 0; i !== folders.length; ++i) {
const folder = folders[i];
for (let j = 0; j !== folders[i].files.length; ++j) {
const file = folder.files[j];
delete file.password;
(folder.files[j] as unknown as { url: string }).url = formatRootUrl(
config.uploader.route,
folder.files[j].name
);
}
}
}
return res.json(folders);
}
}
export default withZipline(handler, {
methods: ['GET', 'POST'],
user: true,
});

View file

@ -1,5 +1,6 @@
import config from 'lib/config';
import prisma from 'lib/prisma';
import { formatRootUrl } from 'lib/utils/urls';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
const pageCount = 16;
@ -56,6 +57,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
expiresAt: Date;
maxViews: number;
views: number;
folderId: number;
}[] = await prisma.file.findMany({
where,
orderBy: {
@ -70,15 +72,14 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
favorite: true,
views: true,
maxViews: true,
folderId: true,
},
skip: page ? (Number(page) - 1) * pageCount : undefined,
take: page ? pageCount : undefined,
});
for (let i = 0; i !== files.length; ++i) {
(files[i] as unknown as { url: string }).url = `${
config.uploader.route === '/' ? '/' : `${config.uploader.route}/`
}${files[i].name}`;
(files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
}
return res.json(files);

View file

@ -1,5 +1,6 @@
import config from 'lib/config';
import prisma from 'lib/prisma';
import { formatRootUrl } from 'lib/utils/urls';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
@ -23,13 +24,12 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
id: true,
views: true,
maxViews: true,
folderId: true,
},
});
for (let i = 0; i !== files.length; ++i) {
(files[i] as unknown as { url: string }).url = `${
config.uploader.route === '/' ? '/' : `${config.uploader.route}/`
}${files[i].name}`;
(files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
}
if (req.query.filter && req.query.filter === 'media')

View file

@ -1,6 +1,7 @@
import config from 'lib/config';
import Logger from 'lib/logger';
import prisma from 'lib/prisma';
import { formatRootUrl } from 'lib/utils/urls';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
@ -34,11 +35,12 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
},
});
urls.map(
(url) =>
// @ts-ignore
(url.url = `${config.urls.route === '/' ? '/' : `${config.urls.route}/`}${url.vanity ?? url.id}`)
);
for (let i = 0; i !== urls.length; ++i) {
(urls[i] as unknown as { url: string }).url = formatRootUrl(
config.urls.route,
urls[i].vanity ?? urls[i].id
);
}
return res.json(urls);
}
}

View file

@ -0,0 +1,25 @@
import { LoadingOverlay } from '@mantine/core';
import Layout from 'components/Layout';
import Folders from 'components/pages/Folders';
import useLogin from 'hooks/useLogin';
import Head from 'next/head';
export { getServerSideProps } from 'middleware/getServerSideProps';
export default function FilesPage(props) {
const { loading } = useLogin();
if (loading) return <LoadingOverlay visible={loading} />;
const title = `${props.title} - Folders`;
return (
<>
<Head>
<title>{title}</title>
</Head>
<Layout props={props}>
<Folders disableMediaPreview={props.disable_media_preview} exifEnabled={props.exif_enabled} />
</Layout>
</>
);
}