Compare commits

...

4 Commits

Author SHA1 Message Date
diced
adb984b2db ima kms 2023-04-30 15:21:25 -07:00
dicedtomato
3be9f1521e Merge branch 'trunk' into feature/file-tags 2023-04-04 20:08:03 -07:00
dicedtomato
5d971a9fef Merge branch 'trunk' into feature/file-tags 2023-04-04 19:13:05 -07:00
diced
2c86abbf4e feat: file tags (experimental) 2023-04-04 19:12:06 -07:00
12 changed files with 834 additions and 37 deletions

View File

@@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT NOT NULL,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_FileToTag" (
"A" INTEGER NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "_FileToTag_AB_unique" ON "_FileToTag"("A", "B");
-- CreateIndex
CREATE INDEX "_FileToTag_B_index" ON "_FileToTag"("B");
-- AddForeignKey
ALTER TABLE "_FileToTag" ADD CONSTRAINT "_FileToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "File"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_FileToTag" ADD CONSTRAINT "_FileToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -60,8 +60,18 @@ model File {
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId Int?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
folderId Int?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
folderId Int?
tags Tag[]
}
model Tag {
id String @id @default(cuid())
name String
color String
files File[]
}
model InvisibleFile {

View File

@@ -3,11 +3,14 @@ import {
Group,
LoadingOverlay,
Modal,
MultiSelect,
Select,
SimpleGrid,
Stack,
Title,
Tooltip,
Text,
Accordion,
} from '@mantine/core';
import { useClipboard, useMediaQuery } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
@@ -24,21 +27,27 @@ import {
IconFolderCancel,
IconFolderMinus,
IconFolderPlus,
IconFolders,
IconHash,
IconInfoCircle,
IconPhoto,
IconPhotoCancel,
IconPhotoMinus,
IconPhotoStar,
IconPlus,
IconTags,
} from '@tabler/icons-react';
import useFetch, { ApiError } from 'hooks/useFetch';
import { useFileDelete, useFileFavorite, UserFilesResponse } from 'lib/queries/files';
import { useFolders } from 'lib/queries/folders';
import { bytesToHuman } from 'lib/utils/bytes';
import { relativeTime } from 'lib/utils/client';
import { colorHash, relativeTime } from 'lib/utils/client';
import { useState } from 'react';
import { FileMeta } from '.';
import Type from '../Type';
import Tag from 'components/File/tag/Tag';
import Item from 'components/File/tag/Item';
import { useDeleteFileTags, useFileTags, useTags, useUpdateFileTags } from 'lib/queries/tags';
export default function FileModal({
open,
@@ -62,9 +71,14 @@ export default function FileModal({
const deleteFile = useFileDelete();
const favoriteFile = useFileFavorite();
const folders = useFolders();
const tags = useFileTags(file.id);
const updateTags = useUpdateFileTags(file.id);
const removeTags = useDeleteFileTags(file.id);
const clipboard = useClipboard();
const allTags = useTags();
const [overrideRender, setOverrideRender] = useState(false);
const clipboard = useClipboard();
const handleDelete = async () => {
deleteFile.mutate(file.id, {
@@ -209,12 +223,50 @@ export default function FileModal({
return { value: t, label: t };
};
const handleTagsSave = () => {
console.log('should save');
};
const handleAddTags = (t: string[]) => {
// filter out existing tags from t
t = t.filter((tag) => !tags.data.find((t) => t.id === tag));
const fullTag = allTags.data.find((tag) => tag.id === t[0]);
if (!fullTag) return;
updateTags.mutate([...tags.data, fullTag], {
onSuccess: () => {
showNotification({
title: 'Added tag',
message: fullTag.name,
color: 'green',
icon: <IconTags size='1rem' />,
});
},
});
};
const handleRemoveTags = (t: string[]) => {
const fullTag = allTags.data.find((tag) => tag.id === t[0]);
removeTags.mutate(t, {
onSuccess: () =>
showNotification({
title: 'Removed tag',
message: fullTag.name,
color: 'green',
icon: <IconTags size='1rem' />,
}),
});
};
return (
<Modal
opened={open}
onClose={() => setOpen(false)}
title={<Title>{file.name}</Title>}
size='auto'
size='lg'
fullScreen={useMediaQuery('(max-width: 600px)')}
>
<LoadingOverlay visible={loading} />
@@ -224,8 +276,6 @@ export default function FileModal({
src={`/r/${encodeURI(file.name)}?compress=${compress}`}
alt={file.name}
popup
sx={{ minHeight: 200 }}
style={{ minHeight: 200 }}
disableMediaPreview={false}
overrideRender={overrideRender}
setOverrideRender={setOverrideRender}
@@ -269,6 +319,98 @@ export default function FileModal({
</SimpleGrid>
</Stack>
{!reducedActions ? (
<Accordion
variant='contained'
mb='sm'
styles={(t) => ({
content: { backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[7] : t.colors.gray[0] },
control: { backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[7] : t.colors.gray[0] },
})}
>
<Accordion.Item value='tags'>
<Accordion.Control icon={<IconTags size='1rem' />}>Tags</Accordion.Control>
<Accordion.Panel>
<MultiSelect
value={tags.data?.map((t) => t.id) ?? []}
data={allTags.data?.map((t) => ({ value: t.id, label: t.name, color: t.color })) ?? []}
placeholder={allTags.data?.length ? 'Add tags' : 'Add tags (optional)'}
icon={<IconTags size='1rem' />}
valueComponent={Tag}
itemComponent={Item}
searchable
creatable
getCreateLabel={(t) => (
<Group>
<IconPlus size='1rem' />
<Text ml='sm' display='flex'>
Create tag{' '}
<Text ml={4} color={colorHash(t)}>
&quot;{t}&quot;
</Text>
</Text>
</Group>
)}
// onChange={(t) => (t.length === 1 ? handleRemoveTags(t) : handleAddTags(t))}
onChange={(t) => console.log(t)}
onCreate={(t) => {
const item = { value: t, label: t, color: colorHash(t) };
// setLabelTags([...labelTags, item]);
return item;
}}
onBlur={handleTagsSave}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='folders'>
<Accordion.Control icon={<IconFolders size='1rem' />}>Folders</Accordion.Control>
<Accordion.Panel>
{inFolder && !folders.isLoading ? (
<Group>
<Tooltip
label={`Remove from folder "${
folders.data.find((f) => f.id === file.folderId)?.name ?? ''
}"`}
>
<ActionIcon
color='red'
variant='filled'
onClick={removeFromFolder}
loading={folders.isLoading}
>
<IconFolderMinus size='1rem' />
</ActionIcon>
</Tooltip>
<Text display='flex' align='center'>
Currently in folder &quot;{folders.data.find((f) => f.id === file.folderId)?.name ?? ''}
&quot;
</Text>
</Group>
) : (
<Tooltip label='Add to folder'>
<Select
icon={<IconFolderPlus size='1rem' />}
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>
)}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
) : null}
<Group position='apart' my='md'>
<Group position='left'>
{exifEnabled && !reducedActions && (
@@ -282,32 +424,6 @@ export default function FileModal({
</ActionIcon>
</Tooltip>
)}
{reducedActions ? null : inFolder && !folders.isLoading ? (
<Tooltip
label={`Remove from folder "${folders.data.find((f) => f.id === file.folderId)?.name ?? ''}"`}
>
<ActionIcon color='red' variant='filled' onClick={removeFromFolder} loading={folders.isLoading}>
<IconFolderMinus size='1rem' />
</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 : (

View File

@@ -0,0 +1,17 @@
import { ComponentPropsWithoutRef, forwardRef } from 'react';
import { Group, Text } from '@mantine/core';
interface ItemProps extends ComponentPropsWithoutRef<'div'> {
color: string;
label: string;
}
const Item = forwardRef<HTMLDivElement, ItemProps>(({ color, label, ...others }: ItemProps, ref) => (
<div ref={ref} {...others}>
<Group noWrap>
<Text color={color}>{label}</Text>
</Group>
</div>
));
export default Item;

View File

@@ -0,0 +1,26 @@
import { Box, CloseButton, MultiSelectValueProps, rem } from '@mantine/core';
export default function Tag({
label,
onRemove,
color,
...others
}: MultiSelectValueProps & { color: string }) {
return (
<div {...others}>
<Box
sx={(theme) => ({
display: 'flex',
cursor: 'default',
alignItems: 'center',
backgroundColor: color,
paddingLeft: theme.spacing.xs,
borderRadius: theme.radius.sm,
})}
>
<Box sx={{ lineHeight: 1, fontSize: rem(12) }}>{label}</Box>
<CloseButton onMouseDown={onRemove} variant='transparent' size={22} iconSize={14} tabIndex={-1} />
</Box>
</div>
);
}

View File

@@ -0,0 +1,197 @@
import {
ActionIcon,
Button,
ColorInput,
Group,
Modal,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { useDeleteTags, useTags } from 'lib/queries/tags';
import { showNotification } from '@mantine/notifications';
import { IconRefresh, IconTag, IconTags, IconTagsOff } from '@tabler/icons-react';
import { useState } from 'react';
import { colorHash } from 'utils/client';
import useFetch from 'hooks/useFetch';
import { useModals } from '@mantine/modals';
import MutedText from 'components/MutedText';
export function TagCard({ tags, tag }) {
const deleteTags = useDeleteTags();
const modals = useModals();
const deleteTag = () => {
modals.openConfirmModal({
zIndex: 1000,
size: 'auto',
title: (
<Title>
Delete tag <b style={{ color: tag.color }}>{tag.name}</b>?
</Title>
),
children: `This will remove the tag from ${tag.files.length} file${tag.files.length === 1 ? '' : 's'}`,
labels: {
confirm: 'Delete',
cancel: 'Cancel',
},
onCancel() {
modals.closeAll();
},
onConfirm() {
deleteTags.mutate([tag.id], {
onSuccess: () => {
showNotification({
title: 'Tag deleted',
message: `Tag ${tag.name} was deleted`,
color: 'green',
icon: <IconTags size='1rem' />,
});
modals.closeAll();
tags.refetch();
},
});
},
});
};
return (
<Paper
radius='sm'
sx={(t) => ({
backgroundColor: tag.color,
'&:hover': {
backgroundColor: t.fn.darken(tag.color, 0.1),
},
cursor: 'pointer',
})}
px='xs'
onClick={deleteTag}
>
<Group position='apart'>
<Text>
{tag.name} ({tag.files.length})
</Text>
</Group>
</Paper>
);
}
export function CreateTagModal({ tags, open, onClose }) {
const [color, setColor] = useState('');
const [name, setName] = useState('');
const [colorError, setColorError] = useState('');
const [nameError, setNameError] = useState('');
const onSubmit = async (e) => {
e.preventDefault();
setNameError('');
setColorError('');
const n = name.trim();
const c = color.trim();
if (n.length === 0 && c.length === 0) {
setNameError('Name is required');
setColorError('Color is required');
return;
} else if (n.length === 0) {
setNameError('Name is required');
setColorError('');
return;
} else if (c.length === 0) {
setNameError('');
setColorError('Color is required');
return;
}
const data = await useFetch('/api/user/tags', 'POST', {
tags: [
{
name: n,
color: c,
},
],
});
if (!data.error) {
showNotification({
title: 'Tag created',
message: (
<>
Tag <b style={{ color: color }}>{name}</b> was created
</>
),
color: 'green',
icon: <IconTags size='1rem' />,
});
tags.refetch();
onClose();
} else {
showNotification({
title: 'Error creating tag',
message: data.error,
color: 'red',
icon: <IconTagsOff size='1rem' />,
});
}
};
return (
<Modal title={<Title>Create Tag</Title>} size='xs' opened={open} onClose={onClose} zIndex={300}>
<form onSubmit={onSubmit}>
<TextInput
icon={<IconTag size='1rem' />}
label='Name'
value={name}
onChange={(e) => setName(e.currentTarget.value)}
error={nameError}
/>
<ColorInput
dropdownZIndex={301}
label='Color'
value={color}
onChange={setColor}
error={colorError}
rightSection={
<Tooltip label='Generate color from name'>
<ActionIcon variant='subtle' onClick={() => setColor(colorHash(name))} color='primary'>
<IconRefresh size='1rem' />
</ActionIcon>
</Tooltip>
}
/>
<Button type='submit' fullWidth variant='outline' my='sm'>
Create Tag
</Button>
</form>
</Modal>
);
}
export default function TagsModal({ open, onClose }) {
const tags = useTags();
const [createOpen, setCreateOpen] = useState(false);
return (
<>
<CreateTagModal tags={tags} open={createOpen} onClose={() => setCreateOpen(false)} />
<Modal title={<Title>Tags</Title>} size='auto' opened={open} onClose={onClose}>
<MutedText size='sm'>Click on a tag to delete it.</MutedText>
<Stack>
{tags.isSuccess && tags.data.map((tag) => <TagCard key={tag.id} tags={tags} tag={tag} />)}
</Stack>
<Button mt='xl' variant='outline' onClick={() => setCreateOpen(true)} fullWidth compact>
Create Tag
</Button>
</Modal>
</>
);
}

View File

@@ -1,5 +1,5 @@
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Title, Tooltip } from '@mantine/core';
import { IconFileUpload, IconPhotoUp } from '@tabler/icons-react';
import { IconFileUpload, IconPhotoUp, IconTags } from '@tabler/icons-react';
import File from 'components/File';
import useFetch from 'hooks/useFetch';
import { usePaginatedFiles } from 'lib/queries/files';
@@ -7,13 +7,15 @@ import Link from 'next/link';
import { useEffect, useState } from 'react';
import FilePagation from './FilePagation';
import PendingFilesModal from './PendingFilesModal';
import TagsModal from 'components/pages/Files/TagsModal';
export default function Files({ disableMediaPreview, exifEnabled, queryPage, compress }) {
const [favoritePage, setFavoritePage] = useState(1);
const [favoriteNumPages, setFavoriteNumPages] = useState(0);
const favoritePages = usePaginatedFiles(favoritePage, 'media', true);
const [open, setOpen] = useState(false);
const [pendingOpen, setPendingOpen] = useState(false);
const [tagsOpen, setTagsOpen] = useState(false);
useEffect(() => {
(async () => {
@@ -24,7 +26,8 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage, com
return (
<>
<PendingFilesModal open={open} onClose={() => setOpen(false)} />
<PendingFilesModal open={pendingOpen} onClose={() => setPendingOpen(false)} />
<TagsModal open={tagsOpen} onClose={() => setTagsOpen(false)} />
<Group mb='md'>
<Title>Files</Title>
@@ -33,10 +36,15 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage, com
</ActionIcon>
<Tooltip label='View pending uploads'>
<ActionIcon onClick={() => setOpen(true)} variant='filled' color='primary'>
<ActionIcon onClick={() => setPendingOpen(true)} variant='filled' color='primary'>
<IconPhotoUp size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='View tags'>
<ActionIcon onClick={() => setTagsOpen(true)} variant='filled' color='primary'>
<IconTags size='1rem' />
</ActionIcon>
</Tooltip>
</Group>
{favoritePages.isSuccess && favoritePages.data.length ? (
<Accordion

168
src/lib/queries/tags.ts Normal file
View File

@@ -0,0 +1,168 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import queryClient from 'lib/queries/client';
export type UserTagsResponse = {
id: string;
name: string;
color: string;
files: {
id: string;
}[];
};
export type TagsRequest = {
id?: string;
name?: string;
color?: string;
};
export const useTags = () => {
return useQuery<UserTagsResponse[]>(['tags'], async () => {
return fetch('/api/user/tags')
.then((res) => res.json() as Promise<UserTagsResponse[]>)
.then((data) => data);
});
};
export const useFileTags = (id: string) => {
return useQuery<UserTagsResponse[]>(['tags', id], async () => {
return fetch(`/api/user/file/${id}/tags`)
.then((res) => res.json() as Promise<UserTagsResponse[]>)
.then((data) => data);
});
};
export const useUpdateFileTags = (id: string) => {
return useMutation(
(tags: TagsRequest[]) =>
fetch(`/api/user/file/${id}/tags`, {
method: 'POST',
body: JSON.stringify({ tags }),
headers: {
'Content-Type': 'application/json',
},
}).then((res) => res.json()),
{
onSuccess: () => {
queryClient.refetchQueries(['tags', id]);
queryClient.refetchQueries(['files']);
},
}
);
};
export const useDeleteFileTags = (id: string) => {
return useMutation(
(tags: string[]) =>
fetch(`/api/user/file/${id}/tags`, {
method: 'DELETE',
body: JSON.stringify({ tags }),
headers: {
'Content-Type': 'application/json',
},
}).then((res) => res.json()),
{
onSuccess: () => {
queryClient.refetchQueries(['tags', id]);
},
}
);
};
export const useDeleteTags = () => {
return useMutation(
(tags: string[]) =>
fetch('/api/user/tags', {
method: 'DELETE',
body: JSON.stringify({ tags }),
headers: {
'Content-Type': 'application/json',
},
}).then((res) => res.json()),
{
onSuccess: () => {
queryClient.refetchQueries(['tags']);
queryClient.refetchQueries(['files']);
},
}
);
};
// export const usePaginatedFiles = (page?: number, filter = 'media', favorite = null) => {
// const queryBuilder = new URLSearchParams({
// page: Number(page || '1').toString(),
// filter,
// ...(favorite !== null && { favorite: favorite.toString() }),
// });
// const queryString = queryBuilder.toString();
//
// return useQuery<UserFilesResponse[]>(['files', queryString], async () => {
// return fetch('/api/user/paged?' + queryString)
// .then((res) => res.json() as Promise<UserFilesResponse[]>)
// .then((data) =>
// data.map((x) => ({
// ...x,
// createdAt: new Date(x.createdAt),
// expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
// }))
// );
// });
// };
//
// 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,
// createdAt: new Date(x.createdAt),
// expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
// }))
// );
// });
// };
//
// 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']);
// }

16
src/lib/utils/db.ts Normal file
View File

@@ -0,0 +1,16 @@
export function exclude<T, Key extends keyof T>(obj: T, keys: Key[]): Omit<T, Key> {
for (const key of keys) {
delete obj[key];
}
return obj;
}
export function pick<T, Key extends keyof T>(obj: T, keys: Key[]): Pick<T, Key> {
const newObj: unknown = {};
for (const key of keys) {
newObj[key] = obj[key];
}
return newObj as Pick<T, Key>;
}

View File

@@ -0,0 +1,43 @@
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
import { pick } from 'utils/db';
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
let { id } = req.query as { id: string | number };
if (!id) return res.badRequest('no id');
id = Number(id);
if (isNaN(id)) return res.badRequest('invalid id');
const file = await prisma.file.findFirst({
where: { id, userId: user.id },
include: {
tags: true,
invisible: true,
folder: true,
},
});
if (!file) return res.notFound('file not found or not owned by user');
if (req.method === 'DELETE') {
return res.badRequest('file deletions must be done at `DELETE /api/user/files`');
} else {
// @ts-ignore
if (file.password) file.password = true;
if (req.query.pick) {
const picks = (req.query.pick as string).split(',') as (keyof typeof file)[];
return res.json(pick(file, picks));
}
return res.json(file);
}
}
export default withZipline(handler, {
methods: ['GET', 'DELETE'],
user: true,
});

View File

@@ -0,0 +1,104 @@
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
let { id } = req.query as { id: string | number };
if (!id) return res.badRequest('no id');
id = Number(id);
if (isNaN(id)) return res.badRequest('invalid id');
const file = await prisma.file.findFirst({
where: { id, userId: user.id },
select: {
tags: true,
},
});
if (!file) return res.notFound('file not found or not owned by user');
if (req.method === 'DELETE') {
const { tags } = req.body as {
tags: string[];
};
if (!tags) return res.badRequest('no tags');
if (!tags.length) return res.badRequest('no tags');
const nFile = await prisma.file.update({
where: { id },
data: {
tags: {
disconnect: tags.map((tag) => ({ id: tag })),
},
},
select: {
tags: true,
},
});
return res.json(nFile.tags);
} else if (req.method === 'PATCH') {
const { tags } = req.body as {
tags: string[];
};
if (!tags) return res.badRequest('no tags');
if (!tags.length) return res.badRequest('no tags');
const nFile = await prisma.file.update({
where: { id },
data: {
tags: {
connect: tags.map((tag) => ({ id: tag })),
},
},
select: {
tags: true,
},
});
return res.json(nFile.tags);
} else if (req.method === 'POST') {
const { tags } = req.body as {
tags: {
name?: string;
color?: string;
id?: string;
}[];
};
if (!tags) return res.badRequest('no tags');
if (!tags.length) return res.badRequest('no tags');
// if the tag has an id, it means it already exists, so we just connect it
// if it doesn't have an id, we create it and then connect it
const nFile = await prisma.file.update({
where: { id },
data: {
tags: {
connectOrCreate: tags.map((tag) => ({
where: { id: tag.id ?? '' },
create: {
name: tag.name,
color: tag.color,
},
})),
},
},
select: {
tags: true,
},
});
return res.json(nFile.tags);
}
return res.json(file.tags);
}
export default withZipline(handler, {
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
user: true,
});

View File

@@ -0,0 +1,66 @@
import prisma from 'lib/prisma';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const tags = await prisma.tag.findMany({
where: {
files: {
every: {
userId: user.id,
},
},
},
include: {
files: {
select: {
id: true,
},
},
},
});
if (req.method === 'DELETE') {
const { tags: tagIds } = req.body as {
tags: string[];
};
if (!tagIds) return res.badRequest('no tags');
if (!tagIds.length) return res.badRequest('no tags');
const nTags = await prisma.tag.deleteMany({
where: {
id: {
in: tagIds,
},
},
});
return res.json(nTags);
} else if (req.method === 'POST') {
const { tags } = req.body as {
tags: {
name: string;
color: string;
}[];
};
if (!tags) return res.badRequest('no tags');
if (!tags.length) return res.badRequest('no tags');
const nTags = await prisma.tag.createMany({
data: tags.map((tag) => ({
name: tag.name,
color: tag.color,
})),
});
return res.json(nTags);
}
return res.json(tags);
}
export default withZipline(handler, {
methods: ['GET', 'POST', 'DELETE'],
user: true,
});