diff --git a/src/components/File/FileModal.tsx b/src/components/File/FileModal.tsx index bf4c5ad6..debb4f11 100644 --- a/src/components/File/FileModal.tsx +++ b/src/components/File/FileModal.tsx @@ -47,6 +47,7 @@ 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, @@ -70,15 +71,14 @@ export default function FileModal({ const deleteFile = useFileDelete(); const favoriteFile = useFileFavorite(); const folders = useFolders(); - - const [overrideRender, setOverrideRender] = useState(false); + const tags = useFileTags(file.id); + const updateTags = useUpdateFileTags(file.id); + const removeTags = useDeleteFileTags(file.id); const clipboard = useClipboard(); - const [tags, setTags] = useState<{ label: string; value: string; color: string }[]>([ - { label: 'Tag 1', value: 'tag-1', color: '#ff0000' }, - { label: 'Tag 2', value: 'tag-2', color: '#00ff00' }, - { label: 'Tag 3', value: 'tag-3', color: '#0000ff' }, - ]); + const allTags = useTags(); + + const [overrideRender, setOverrideRender] = useState(false); const handleDelete = async () => { deleteFile.mutate(file.id, { @@ -227,6 +227,40 @@ export default function FileModal({ 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: , + }); + }, + }); + }; + + 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: , + }), + }); + }; + return ( }>Tags 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={} valueComponent={Tag} itemComponent={Item} @@ -316,9 +351,11 @@ export default function FileModal({ )} + // 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) }; - setTags([...tags, item]); + // setLabelTags([...labelTags, item]); return item; }} onBlur={handleTagsSave} diff --git a/src/components/pages/Files/TagsModal.tsx b/src/components/pages/Files/TagsModal.tsx new file mode 100644 index 00000000..58745865 --- /dev/null +++ b/src/components/pages/Files/TagsModal.tsx @@ -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: ( + + Delete tag <b style={{ color: tag.color }}>{tag.name}</b>? + + ), + 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: , + }); + modals.closeAll(); + tags.refetch(); + }, + }); + }, + }); + }; + + return ( + ({ + backgroundColor: tag.color, + '&:hover': { + backgroundColor: t.fn.darken(tag.color, 0.1), + }, + cursor: 'pointer', + })} + px='xs' + onClick={deleteTag} + > + + + {tag.name} ({tag.files.length}) + + + + ); +} + +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 {name} was created + + ), + color: 'green', + icon: , + }); + tags.refetch(); + onClose(); + } else { + showNotification({ + title: 'Error creating tag', + message: data.error, + color: 'red', + icon: , + }); + } + }; + + return ( + Create Tag} size='xs' opened={open} onClose={onClose} zIndex={300}> +
+ } + label='Name' + value={name} + onChange={(e) => setName(e.currentTarget.value)} + error={nameError} + /> + + setColor(colorHash(name))} color='primary'> + + + + } + /> + + + +
+ ); +} + +export default function TagsModal({ open, onClose }) { + const tags = useTags(); + + const [createOpen, setCreateOpen] = useState(false); + + return ( + <> + setCreateOpen(false)} /> + Tags} size='auto' opened={open} onClose={onClose}> + Click on a tag to delete it. + + {tags.isSuccess && tags.data.map((tag) => )} + + + + + + ); +} diff --git a/src/components/pages/Files/index.tsx b/src/components/pages/Files/index.tsx index 9a03bbe3..f1d3411f 100644 --- a/src/components/pages/Files/index.tsx +++ b/src/components/pages/Files/index.tsx @@ -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 ( <> - setOpen(false)} /> + setPendingOpen(false)} /> + setTagsOpen(false)} /> Files @@ -33,10 +36,15 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage, com - setOpen(true)} variant='filled' color='primary'> + setPendingOpen(true)} variant='filled' color='primary'> + + setTagsOpen(true)} variant='filled' color='primary'> + + + {favoritePages.isSuccess && favoritePages.data.length ? ( { + return useQuery(['tags'], async () => { + return fetch('/api/user/tags') + .then((res) => res.json() as Promise) + .then((data) => data); + }); +}; + +export const useFileTags = (id: string) => { + return useQuery(['tags', id], async () => { + return fetch(`/api/user/file/${id}/tags`) + .then((res) => res.json() as Promise) + .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(['files', queryString], async () => { +// return fetch('/api/user/paged?' + queryString) +// .then((res) => res.json() as Promise) +// .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(['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']); +// } diff --git a/src/pages/api/user/file/[id]/tags.ts b/src/pages/api/user/file/[id]/tags.ts index 62c497b9..4b6f37bc 100644 --- a/src/pages/api/user/file/[id]/tags.ts +++ b/src/pages/api/user/file/[id]/tags.ts @@ -72,7 +72,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) { 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 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 },