Skip to content

Commit 9038c28

Browse files
authored
Add custom tag modal (#1171)
* feat: add custom tag modal * misc: accommodate requested changes Co-authored-by: Harshith Mohan <26010946+harshithmohan@users.noreply.github.com> [skip ci]
1 parent 1032982 commit 9038c28

File tree

6 files changed

+430
-0
lines changed

6 files changed

+430
-0
lines changed

src/components/Collection/SeriesTopPanel.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import React, { useEffect, useMemo, useState } from 'react';
22
import { useParams } from 'react-router';
3+
import { mdiTagPlusOutline } from '@mdi/js';
4+
import Icon from '@mdi/react';
35
import { toNumber } from 'lodash';
6+
import { useToggle } from 'usehooks-ts';
47

58
import BackgroundImagePlaceholderDiv from '@/components/BackgroundImagePlaceholderDiv';
69
import CleanDescription from '@/components/Collection/CleanDescription';
710
import SeriesInfo from '@/components/Collection/SeriesInfo';
811
import SeriesUserStats from '@/components/Collection/SeriesUserStats';
912
import TagButton from '@/components/Collection/TagButton';
13+
import CustomTagModal from '@/components/Dialogs/CustomTagModal';
14+
import Button from '@/components/Input/Button';
1015
import ShokoPanel from '@/components/Panels/ShokoPanel';
1116
import { useSeriesImagesQuery, useSeriesTagsQuery } from '@/core/react-query/series/queries';
1217
import { useSettingsQuery } from '@/core/react-query/settings/queries';
@@ -23,6 +28,8 @@ const SeriesTopPanel = React.memo(({ series }: { series: SeriesType }) => {
2328
const { showRandomPoster } = useSettingsQuery().data.WebUI_Settings.collection.image;
2429
const imagesQuery = useSeriesImagesQuery(toNumber(seriesId!), !!seriesId && showRandomPoster);
2530
const [poster, setPoster] = useState<ImageType>();
31+
const [showTagModal, toggleTagModal] = useToggle(false);
32+
2633
useEffect(() => {
2734
if (!showRandomPoster) {
2835
setPoster(series.Images?.Posters?.[0]);
@@ -78,7 +85,15 @@ const SeriesTopPanel = React.memo(({ series }: { series: SeriesType }) => {
7885
contentClassName="!flex-row flex-wrap gap-3 content-start contain-strict"
7986
isFetching={tagsQuery.isFetching}
8087
transparent
88+
options={
89+
<div className="flex gap-x-2">
90+
<Button onClick={toggleTagModal} tooltip="Edit Tags">
91+
<Icon className="text-panel-icon-important" path={mdiTagPlusOutline} size={1} />
92+
</Button>
93+
</div>
94+
}
8195
>
96+
<CustomTagModal seriesId={toNumber(seriesId)} show={showTagModal} onClose={toggleTagModal} />
8297
{tags.slice(0, 10)
8398
.map(tag => <TagButton key={tag.ID} text={tag.Name} tagType={tag.Source} type="Series" />)}
8499
</ShokoPanel>
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
import React, { useLayoutEffect, useMemo, useState } from 'react';
2+
import { mdiPencilCircleOutline, mdiPlusCircleOutline } from '@mdi/js';
3+
import Icon from '@mdi/react';
4+
import cx from 'classnames';
5+
6+
import Button from '@/components/Input/Button';
7+
import Input from '@/components/Input/Input';
8+
import ModalPanel from '@/components/Panels/ModalPanel';
9+
import { invalidateQueries } from '@/core/react-query/queryClient';
10+
import { useSeriesUserTagsSetQuery } from '@/core/react-query/series/queries';
11+
import {
12+
useAddUserTagMutation,
13+
useCreateUserTagMutation,
14+
useDeleteUserTagMutation,
15+
useRemoveUserTagMutation,
16+
useUpdateUserTagMutation,
17+
} from '@/core/react-query/tag/mutations';
18+
import { useUserTagsQuery } from '@/core/react-query/tag/queries';
19+
import useEventCallback from '@/hooks/useEventCallback';
20+
21+
export type Props = {
22+
seriesId: number;
23+
show: boolean;
24+
onClose: () => void;
25+
};
26+
27+
function CustomTagModal({ onClose, seriesId, show }: Props) {
28+
const userTagsQuery = useUserTagsQuery({ pageSize: 0, includeCount: true }, show);
29+
const activeTagSetQuery = useSeriesUserTagsSetQuery(seriesId, show);
30+
const { mutate: addUserTagMutation } = useAddUserTagMutation();
31+
const { mutate: removeUserTagMutation } = useRemoveUserTagMutation();
32+
const { mutate: createUserTagMutation } = useCreateUserTagMutation();
33+
const { mutate: updateTagMutation } = useUpdateUserTagMutation();
34+
const { mutate: deleteTagMutation } = useDeleteUserTagMutation();
35+
const [selectedTagId, setSelectedTagId] = useState<number | null>(null);
36+
const selectedTag = useMemo(() => userTagsQuery.data?.find(tag => tag.ID === selectedTagId) ?? null, [
37+
userTagsQuery.data,
38+
selectedTagId,
39+
]);
40+
const [mode, setMode] = useState<'create' | 'edit' | null>(null);
41+
const [tagName, setTagName] = useState('');
42+
const [tagDesc, setTagDescription] = useState('');
43+
44+
const activeTagSet = activeTagSetQuery.data;
45+
const lockedControls = !mode || (mode === 'edit' && !selectedTag);
46+
const lockedTag = mode === 'create';
47+
const canCreate = mode === 'create' && tagName && tagName.length > 0;
48+
const changed = mode === 'edit' && selectedTag
49+
&& (selectedTag.Name !== tagName || selectedTag.Description !== tagDesc);
50+
51+
let subHeader = 'Add/Remove Tags';
52+
if (mode === 'edit') subHeader = 'Edit Tags';
53+
if (mode === 'create') subHeader = 'Create Tag';
54+
55+
const handleTagNameChange = useEventCallback((event: React.ChangeEvent<HTMLInputElement>) => {
56+
if (lockedControls) return;
57+
setTagName(event.target.value);
58+
});
59+
60+
const handleTagDescChange = useEventCallback((event: React.ChangeEvent<HTMLInputElement>) => {
61+
if (lockedControls) return;
62+
setTagDescription(event.target.value);
63+
});
64+
65+
const handleTagClick = useEventCallback((event: React.MouseEvent<HTMLElement>) => {
66+
if (lockedTag) return;
67+
68+
const selectedTagId1 = parseInt(event.currentTarget.dataset.tagId ?? '0', 10);
69+
if (Number.isNaN(selectedTagId1) || !selectedTagId1) return;
70+
const selectedTag1 = userTagsQuery.data?.find(tag => tag.ID === selectedTagId1) ?? null;
71+
if (selectedTag1 && selectedTag && selectedTag1.ID === selectedTag.ID) {
72+
setSelectedTagId(null);
73+
setTagName('');
74+
setTagDescription('');
75+
} else {
76+
setSelectedTagId(selectedTag1?.ID ?? null);
77+
setTagName(selectedTag1?.Name ?? '');
78+
setTagDescription(selectedTag1?.Description ?? '');
79+
}
80+
});
81+
82+
const handleClose = useEventCallback(() => {
83+
setMode(null);
84+
setSelectedTagId(null);
85+
setTagName('');
86+
setTagDescription('');
87+
invalidateQueries(['series', seriesId, 'tags']);
88+
onClose();
89+
});
90+
91+
const handleCancel = useEventCallback(() => {
92+
if (mode === 'create') {
93+
setMode(null);
94+
setSelectedTagId(null);
95+
setTagName('');
96+
setTagDescription('');
97+
} else if (mode === 'edit') {
98+
setMode(null);
99+
setTagName(selectedTag?.Name ?? '');
100+
setTagDescription(selectedTag?.Description ?? '');
101+
} else if (mode === 'remove') {
102+
setMode(null);
103+
setTagName(selectedTag?.Name ?? '');
104+
setTagDescription(selectedTag?.Description ?? '');
105+
} else if (selectedTag) {
106+
setSelectedTagId(null);
107+
setTagName('');
108+
setTagDescription('');
109+
} else {
110+
invalidateQueries(['series', seriesId, 'tags']);
111+
onClose();
112+
}
113+
});
114+
115+
const handleDelete = useEventCallback(() => {
116+
if (!selectedTag) return;
117+
deleteTagMutation(selectedTag.ID, {
118+
onSuccess: () => {
119+
setMode(null);
120+
setSelectedTagId(null);
121+
setTagName('');
122+
setTagDescription('');
123+
},
124+
});
125+
});
126+
127+
const handleSave = useEventCallback(() => {
128+
if (!selectedTag) return;
129+
updateTagMutation({ tagId: selectedTag.ID, name: tagName, description: tagDesc });
130+
});
131+
132+
const handleCreate = useEventCallback(() => {
133+
createUserTagMutation({ name: tagName, description: tagDesc || null }, {
134+
onSuccess: (tag) => {
135+
setMode(null);
136+
setSelectedTagId(tag.ID);
137+
setTagName(tag.Name);
138+
setTagDescription(tag.Description ?? '');
139+
removeUserTagMutation({ seriesId, tagId: tag.ID });
140+
},
141+
});
142+
});
143+
144+
const handleAdd = useEventCallback(() => {
145+
if (!selectedTag) return;
146+
addUserTagMutation({ seriesId, tagId: selectedTag.ID });
147+
});
148+
149+
const handleRemove = useEventCallback(() => {
150+
if (!selectedTag) return;
151+
removeUserTagMutation({ seriesId, tagId: selectedTag.ID });
152+
});
153+
154+
const handleEditModeToggle = useEventCallback(() => {
155+
setMode('edit');
156+
setTagName(selectedTag?.Name ?? '');
157+
setTagDescription(selectedTag?.Description ?? '');
158+
});
159+
160+
const handleCreateModeToggle = useEventCallback(() => {
161+
setMode('create');
162+
setSelectedTagId(null);
163+
setTagName('');
164+
setTagDescription('');
165+
});
166+
167+
const buttons = useMemo(() => {
168+
if (mode === 'create') {
169+
return (
170+
<>
171+
<Button key="add-cancel" onClick={handleCancel} buttonType="secondary" className="px-6 py-2">Cancel</Button>
172+
<Button
173+
key="add-confirm"
174+
onClick={handleCreate}
175+
buttonType="primary"
176+
disabled={!canCreate}
177+
className="px-6 py-2"
178+
>
179+
Create
180+
</Button>
181+
</>
182+
);
183+
}
184+
if (mode === 'edit') {
185+
return (
186+
<>
187+
<Button
188+
key="edit-delete"
189+
onClick={handleDelete}
190+
buttonType="secondary"
191+
disabled={!selectedTag}
192+
className="px-6 py-2"
193+
>
194+
Delete
195+
</Button>
196+
<Button key="edit-cancel" onClick={handleCancel} buttonType="secondary" className="px-6 py-2">Cancel</Button>
197+
<Button key="edit-save" onClick={handleSave} buttonType="primary" disabled={!changed} className="px-6 py-2">
198+
Save
199+
</Button>
200+
</>
201+
);
202+
}
203+
if (selectedTag && activeTagSet.has(selectedTag.ID)) {
204+
return (
205+
<>
206+
<Button key="remove-cancel" onClick={handleCancel} buttonType="secondary" className="px-6 py-2">
207+
Cancel
208+
</Button>
209+
<Button key="remove" onClick={handleRemove} buttonType="danger" disabled={!selectedTag} className="px-6 py-2">
210+
Remove
211+
</Button>
212+
</>
213+
);
214+
}
215+
if (selectedTag) {
216+
return (
217+
<>
218+
<Button key="add-cancel" onClick={handleCancel} buttonType="secondary" className="px-6 py-2">Cancel</Button>
219+
<Button key="add" onClick={handleAdd} buttonType="primary" disabled={!selectedTag} className="px-6 py-2">
220+
Add
221+
</Button>
222+
</>
223+
);
224+
}
225+
return <Button key="cancel" onClick={handleCancel} buttonType="secondary" className="px-6 py-2">Close</Button>;
226+
}, [
227+
activeTagSet,
228+
canCreate,
229+
changed,
230+
handleAdd,
231+
handleCancel,
232+
handleCreate,
233+
handleDelete,
234+
handleRemove,
235+
handleSave,
236+
mode,
237+
selectedTag,
238+
]);
239+
240+
useLayoutEffect(() => {
241+
if (show) {
242+
userTagsQuery.refetch().catch(console.error);
243+
activeTagSetQuery.refetch().catch(console.error);
244+
}
245+
// eslint-disable-next-line react-hooks/exhaustive-deps
246+
}, [show, seriesId]);
247+
248+
return (
249+
<ModalPanel
250+
show={show}
251+
onRequestClose={handleClose}
252+
header="Custom Tags"
253+
size="sm"
254+
overlayClassName="!z-[90]"
255+
subHeader={subHeader}
256+
>
257+
<div className="flex grow flex-col gap-y-2">
258+
<div className="flex justify-between">
259+
<div className="mb-2 font-semibold">
260+
Available Tags
261+
</div>
262+
<div className="flex gap-x-2">
263+
<Button onClick={handleEditModeToggle} disabled={!!mode} tooltip="Edit Tags">
264+
<Icon className="text-panel-icon-action" path={mdiPencilCircleOutline} size={1} />
265+
</Button>
266+
<Button onClick={handleCreateModeToggle} disabled={!!mode} tooltip="Create Tag">
267+
<Icon className="text-panel-icon-action" path={mdiPlusCircleOutline} size={1} />
268+
</Button>
269+
</div>
270+
</div>
271+
<div className="flex h-[10.5rem] flex-col overflow-y-auto rounded-md border border-panel-border bg-panel-background-alt px-4 py-2 contain-strict">
272+
{userTagsQuery.data?.map(tag => (
273+
<div
274+
key={tag.ID}
275+
data-tag-id={tag.ID}
276+
onClick={handleTagClick}
277+
className={cx(
278+
'flex flex-row justify-between',
279+
lockedTag && 'opacity-65',
280+
!lockedTag && 'cursor-pointer',
281+
!lockedTag && activeTagSet.has(tag.ID) && (!selectedTag || selectedTag.ID !== tag.ID)
282+
&& 'text-panel-text-primary',
283+
selectedTag?.ID === tag.ID && 'text-panel-text-important',
284+
)}
285+
>
286+
<span>
287+
{tag.Name}
288+
</span>
289+
<span className="w-10 text-center">
290+
{tag.Size ?? 0}
291+
</span>
292+
</div>
293+
))}
294+
</div>
295+
</div>
296+
<div>
297+
<div className="mb-2 font-semibold">
298+
Name
299+
</div>
300+
<Input
301+
id="tag-name"
302+
type="text"
303+
disabled={lockedControls}
304+
value={tagName}
305+
onChange={handleTagNameChange}
306+
className={cx(
307+
lockedControls && 'opacity-65',
308+
)}
309+
/>
310+
</div>
311+
<div>
312+
<div className="mb-2 font-semibold">
313+
Description
314+
</div>
315+
<Input
316+
id="tag-desc"
317+
type="text"
318+
disabled={lockedControls}
319+
value={tagDesc}
320+
onChange={handleTagDescChange}
321+
className={cx(
322+
lockedControls && 'opacity-65',
323+
)}
324+
/>
325+
</div>
326+
<div className="flex justify-end gap-x-3 font-semibold">
327+
{buttons}
328+
</div>
329+
</ModalPanel>
330+
);
331+
}
332+
333+
export default CustomTagModal;

src/core/react-query/series/queries.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,15 @@ export const useSeriesTagsQuery = (seriesId: number, params: SeriesTagsRequestTy
134134
enabled,
135135
});
136136

137+
export const useSeriesUserTagsSetQuery = (seriesId: number, enabled = true) =>
138+
useQuery<TagType[], unknown, Set<number>>({
139+
queryKey: ['series', seriesId, 'tags', 'user'],
140+
queryFn: () => axios.get(`Series/${seriesId}/Tags/User`),
141+
select: data => new Set(data.map(tag => tag.ID)),
142+
enabled,
143+
initialData: [],
144+
});
145+
137146
export const useSeriesWithLinkedFilesInfiniteQuery = (params: SeriesWithLinkedFilesRequestType) =>
138147
useInfiniteQuery<ListResultType<SeriesType>>({
139148
queryKey: ['series', 'linked-files', params],

0 commit comments

Comments
 (0)