diff --git a/src/components/Collection/SeriesTopPanel.tsx b/src/components/Collection/SeriesTopPanel.tsx index 0b133ebc4..27e81fb71 100644 --- a/src/components/Collection/SeriesTopPanel.tsx +++ b/src/components/Collection/SeriesTopPanel.tsx @@ -1,12 +1,17 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router'; +import { mdiTagPlusOutline } from '@mdi/js'; +import Icon from '@mdi/react'; import { toNumber } from 'lodash'; +import { useToggle } from 'usehooks-ts'; import BackgroundImagePlaceholderDiv from '@/components/BackgroundImagePlaceholderDiv'; import CleanDescription from '@/components/Collection/CleanDescription'; import SeriesInfo from '@/components/Collection/SeriesInfo'; import SeriesUserStats from '@/components/Collection/SeriesUserStats'; import TagButton from '@/components/Collection/TagButton'; +import CustomTagModal from '@/components/Dialogs/CustomTagModal'; +import Button from '@/components/Input/Button'; import ShokoPanel from '@/components/Panels/ShokoPanel'; import { useSeriesImagesQuery, useSeriesTagsQuery } from '@/core/react-query/series/queries'; import { useSettingsQuery } from '@/core/react-query/settings/queries'; @@ -23,6 +28,8 @@ const SeriesTopPanel = React.memo(({ series }: { series: SeriesType }) => { const { showRandomPoster } = useSettingsQuery().data.WebUI_Settings.collection.image; const imagesQuery = useSeriesImagesQuery(toNumber(seriesId!), !!seriesId && showRandomPoster); const [poster, setPoster] = useState(); + const [showTagModal, toggleTagModal] = useToggle(false); + useEffect(() => { if (!showRandomPoster) { setPoster(series.Images?.Posters?.[0]); @@ -78,7 +85,15 @@ const SeriesTopPanel = React.memo(({ series }: { series: SeriesType }) => { contentClassName="!flex-row flex-wrap gap-3 content-start contain-strict" isFetching={tagsQuery.isFetching} transparent + options={ +
+ +
+ } > + {tags.slice(0, 10) .map(tag => )} diff --git a/src/components/Dialogs/CustomTagModal.tsx b/src/components/Dialogs/CustomTagModal.tsx new file mode 100644 index 000000000..ceadeafbc --- /dev/null +++ b/src/components/Dialogs/CustomTagModal.tsx @@ -0,0 +1,333 @@ +import React, { useLayoutEffect, useMemo, useState } from 'react'; +import { mdiPencilCircleOutline, mdiPlusCircleOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import cx from 'classnames'; + +import Button from '@/components/Input/Button'; +import Input from '@/components/Input/Input'; +import ModalPanel from '@/components/Panels/ModalPanel'; +import { invalidateQueries } from '@/core/react-query/queryClient'; +import { useSeriesUserTagsSetQuery } from '@/core/react-query/series/queries'; +import { + useAddUserTagMutation, + useCreateUserTagMutation, + useDeleteUserTagMutation, + useRemoveUserTagMutation, + useUpdateUserTagMutation, +} from '@/core/react-query/tag/mutations'; +import { useUserTagsQuery } from '@/core/react-query/tag/queries'; +import useEventCallback from '@/hooks/useEventCallback'; + +export type Props = { + seriesId: number; + show: boolean; + onClose: () => void; +}; + +function CustomTagModal({ onClose, seriesId, show }: Props) { + const userTagsQuery = useUserTagsQuery({ pageSize: 0, includeCount: true }, show); + const activeTagSetQuery = useSeriesUserTagsSetQuery(seriesId, show); + const { mutate: addUserTagMutation } = useAddUserTagMutation(); + const { mutate: removeUserTagMutation } = useRemoveUserTagMutation(); + const { mutate: createUserTagMutation } = useCreateUserTagMutation(); + const { mutate: updateTagMutation } = useUpdateUserTagMutation(); + const { mutate: deleteTagMutation } = useDeleteUserTagMutation(); + const [selectedTagId, setSelectedTagId] = useState(null); + const selectedTag = useMemo(() => userTagsQuery.data?.find(tag => tag.ID === selectedTagId) ?? null, [ + userTagsQuery.data, + selectedTagId, + ]); + const [mode, setMode] = useState<'create' | 'edit' | null>(null); + const [tagName, setTagName] = useState(''); + const [tagDesc, setTagDescription] = useState(''); + + const activeTagSet = activeTagSetQuery.data; + const lockedControls = !mode || (mode === 'edit' && !selectedTag); + const lockedTag = mode === 'create'; + const canCreate = mode === 'create' && tagName && tagName.length > 0; + const changed = mode === 'edit' && selectedTag + && (selectedTag.Name !== tagName || selectedTag.Description !== tagDesc); + + let subHeader = 'Add/Remove Tags'; + if (mode === 'edit') subHeader = 'Edit Tags'; + if (mode === 'create') subHeader = 'Create Tag'; + + const handleTagNameChange = useEventCallback((event: React.ChangeEvent) => { + if (lockedControls) return; + setTagName(event.target.value); + }); + + const handleTagDescChange = useEventCallback((event: React.ChangeEvent) => { + if (lockedControls) return; + setTagDescription(event.target.value); + }); + + const handleTagClick = useEventCallback((event: React.MouseEvent) => { + if (lockedTag) return; + + const selectedTagId1 = parseInt(event.currentTarget.dataset.tagId ?? '0', 10); + if (Number.isNaN(selectedTagId1) || !selectedTagId1) return; + const selectedTag1 = userTagsQuery.data?.find(tag => tag.ID === selectedTagId1) ?? null; + if (selectedTag1 && selectedTag && selectedTag1.ID === selectedTag.ID) { + setSelectedTagId(null); + setTagName(''); + setTagDescription(''); + } else { + setSelectedTagId(selectedTag1?.ID ?? null); + setTagName(selectedTag1?.Name ?? ''); + setTagDescription(selectedTag1?.Description ?? ''); + } + }); + + const handleClose = useEventCallback(() => { + setMode(null); + setSelectedTagId(null); + setTagName(''); + setTagDescription(''); + invalidateQueries(['series', seriesId, 'tags']); + onClose(); + }); + + const handleCancel = useEventCallback(() => { + if (mode === 'create') { + setMode(null); + setSelectedTagId(null); + setTagName(''); + setTagDescription(''); + } else if (mode === 'edit') { + setMode(null); + setTagName(selectedTag?.Name ?? ''); + setTagDescription(selectedTag?.Description ?? ''); + } else if (mode === 'remove') { + setMode(null); + setTagName(selectedTag?.Name ?? ''); + setTagDescription(selectedTag?.Description ?? ''); + } else if (selectedTag) { + setSelectedTagId(null); + setTagName(''); + setTagDescription(''); + } else { + invalidateQueries(['series', seriesId, 'tags']); + onClose(); + } + }); + + const handleDelete = useEventCallback(() => { + if (!selectedTag) return; + deleteTagMutation(selectedTag.ID, { + onSuccess: () => { + setMode(null); + setSelectedTagId(null); + setTagName(''); + setTagDescription(''); + }, + }); + }); + + const handleSave = useEventCallback(() => { + if (!selectedTag) return; + updateTagMutation({ tagId: selectedTag.ID, name: tagName, description: tagDesc }); + }); + + const handleCreate = useEventCallback(() => { + createUserTagMutation({ name: tagName, description: tagDesc || null }, { + onSuccess: (tag) => { + setMode(null); + setSelectedTagId(tag.ID); + setTagName(tag.Name); + setTagDescription(tag.Description ?? ''); + removeUserTagMutation({ seriesId, tagId: tag.ID }); + }, + }); + }); + + const handleAdd = useEventCallback(() => { + if (!selectedTag) return; + addUserTagMutation({ seriesId, tagId: selectedTag.ID }); + }); + + const handleRemove = useEventCallback(() => { + if (!selectedTag) return; + removeUserTagMutation({ seriesId, tagId: selectedTag.ID }); + }); + + const handleEditModeToggle = useEventCallback(() => { + setMode('edit'); + setTagName(selectedTag?.Name ?? ''); + setTagDescription(selectedTag?.Description ?? ''); + }); + + const handleCreateModeToggle = useEventCallback(() => { + setMode('create'); + setSelectedTagId(null); + setTagName(''); + setTagDescription(''); + }); + + const buttons = useMemo(() => { + if (mode === 'create') { + return ( + <> + + + + ); + } + if (mode === 'edit') { + return ( + <> + + + + + ); + } + if (selectedTag && activeTagSet.has(selectedTag.ID)) { + return ( + <> + + + + ); + } + if (selectedTag) { + return ( + <> + + + + ); + } + return ; + }, [ + activeTagSet, + canCreate, + changed, + handleAdd, + handleCancel, + handleCreate, + handleDelete, + handleRemove, + handleSave, + mode, + selectedTag, + ]); + + useLayoutEffect(() => { + if (show) { + userTagsQuery.refetch().catch(console.error); + activeTagSetQuery.refetch().catch(console.error); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [show, seriesId]); + + return ( + +
+
+
+ Available Tags +
+
+ + +
+
+
+ {userTagsQuery.data?.map(tag => ( +
+ + {tag.Name} + + + {tag.Size ?? 0} + +
+ ))} +
+
+
+
+ Name +
+ +
+
+
+ Description +
+ +
+
+ {buttons} +
+
+ ); +} + +export default CustomTagModal; diff --git a/src/core/react-query/series/queries.ts b/src/core/react-query/series/queries.ts index 5cf1b951a..2c9f5aca1 100644 --- a/src/core/react-query/series/queries.ts +++ b/src/core/react-query/series/queries.ts @@ -134,6 +134,15 @@ export const useSeriesTagsQuery = (seriesId: number, params: SeriesTagsRequestTy enabled, }); +export const useSeriesUserTagsSetQuery = (seriesId: number, enabled = true) => + useQuery>({ + queryKey: ['series', seriesId, 'tags', 'user'], + queryFn: () => axios.get(`Series/${seriesId}/Tags/User`), + select: data => new Set(data.map(tag => tag.ID)), + enabled, + initialData: [], + }); + export const useSeriesWithLinkedFilesInfiniteQuery = (params: SeriesWithLinkedFilesRequestType) => useInfiniteQuery>({ queryKey: ['series', 'linked-files', params], diff --git a/src/core/react-query/tag/mutations.ts b/src/core/react-query/tag/mutations.ts new file mode 100644 index 000000000..13a66d80a --- /dev/null +++ b/src/core/react-query/tag/mutations.ts @@ -0,0 +1,55 @@ +import { useMutation } from '@tanstack/react-query'; + +import { axios } from '@/core/axios'; +import { invalidateQueries } from '@/core/react-query/queryClient'; + +import type { + AddRemoveUserTagRequestType, + CreateUserTagRequestType, + UpdateUserTagRequestType, +} from '@/core/react-query/tag/types'; +import type { TagType } from '@/core/types/api/tags'; + +export const useCreateUserTagMutation = () => + useMutation({ + mutationFn: (body: CreateUserTagRequestType) => axios.post('Tag/User', body), + onSuccess: () => { + invalidateQueries(['tags', 'user']); + }, + }); + +export const useUpdateUserTagMutation = () => + useMutation({ + mutationFn: ({ tagId, ...body }: UpdateUserTagRequestType) => axios.put(`Tag/User/${tagId}`, body), + onSuccess: () => { + invalidateQueries(['tags', 'user']); + }, + }); + +export const useDeleteUserTagMutation = () => + useMutation({ + mutationFn: (tagId: number) => axios.delete(`Tag/User/${tagId}`), + onSuccess: () => { + invalidateQueries(['tags', 'user']); + }, + }); + +export const useAddUserTagMutation = () => + useMutation({ + mutationFn: ({ seriesId, tagId }: AddRemoveUserTagRequestType) => + axios.post(`Series/${seriesId}/Tags/User`, { IDs: [tagId] }), + onSuccess: (_, { seriesId }) => { + invalidateQueries(['tags', 'user']); + invalidateQueries(['series', seriesId, 'tags', 'user']); + }, + }); + +export const useRemoveUserTagMutation = () => + useMutation({ + mutationFn: ({ seriesId, tagId }: AddRemoveUserTagRequestType) => + axios.delete(`Series/${seriesId}/Tags/User`, { data: { IDs: [tagId] } }), + onSuccess: (_, { seriesId }) => { + invalidateQueries(['tags', 'user']); + invalidateQueries(['series', seriesId, 'tags', 'user']); + }, + }); diff --git a/src/core/react-query/tag/types.ts b/src/core/react-query/tag/types.ts index f31f003a5..955bedc4b 100644 --- a/src/core/react-query/tag/types.ts +++ b/src/core/react-query/tag/types.ts @@ -2,4 +2,21 @@ import type { PaginationType } from '@/core/types/api'; export type TagsRequestType = { excludeDescriptions?: boolean; + includeCount?: boolean; } & PaginationType; + +export type CreateUserTagRequestType = { + name: string; + description?: string | null; +}; + +export type UpdateUserTagRequestType = { + tagId: number; + name?: string | null; + description?: string | null; +}; + +export type AddRemoveUserTagRequestType = { + tagId: number; + seriesId: number; +}; diff --git a/src/core/types/api/tags.ts b/src/core/types/api/tags.ts index db65bb6d5..f7222c85e 100644 --- a/src/core/types/api/tags.ts +++ b/src/core/types/api/tags.ts @@ -4,5 +4,6 @@ export type TagType = { Description?: string; IsSpoiler: boolean; Weight: number; + Size?: number; Source: 'AniDB' | 'User'; };