Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add custom tag modal #1171

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/components/Collection/SeriesTopPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
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 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';
import useEventCallback from '@/hooks/useEventCallback';

import type { ImageType } from '@/core/types/api/common';
import type { SeriesType } from '@/core/types/api/series';
Expand All @@ -23,6 +28,17 @@ 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<ImageType>();
const [showTagModal, setShowTagModal] = useState(false);
revam marked this conversation as resolved.
Show resolved Hide resolved

const handleEditTagsClickHandler = useEventCallback(() => {
if (!seriesId) return;
revam marked this conversation as resolved.
Show resolved Hide resolved
setShowTagModal(true);
});

const handleCloseModal = useEventCallback(() => {
setShowTagModal(false);
});

useEffect(() => {
if (!showRandomPoster) {
setPoster(series.Images?.Posters?.[0]);
Expand Down Expand Up @@ -78,7 +94,15 @@ const SeriesTopPanel = React.memo(({ series }: { series: SeriesType }) => {
contentClassName="!flex-row flex-wrap gap-3 content-start contain-strict"
isFetching={tagsQuery.isFetching}
transparent
options={
<div className="flex gap-x-2">
<Button onClick={handleEditTagsClickHandler} tooltip="Edit Tags">
<Icon className="text-panel-icon-important" path={mdiTagPlusOutline} size={1} />
</Button>
</div>
}
>
<CustomTagModal seriesId={Number(seriesId)} show={showTagModal} onClose={handleCloseModal} />
revam marked this conversation as resolved.
Show resolved Hide resolved
{tags.slice(0, 10)
.map(tag => <TagButton key={tag.ID} text={tag.Name} tagType={tag.Source} type="Series" />)}
</ShokoPanel>
Expand Down
332 changes: 332 additions & 0 deletions src/components/Dialogs/CustomTagModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,332 @@
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 {
useAddUserTagMutation,
useCreateUserTagMutation,
useDeleteUserTagMutation,
useRemoveUserTagMutation,
useUpdateUserTagMutation,
} from '@/core/react-query/tag/mutations';
import { useSeriesUserTagsSetQuery, 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<number | null>(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<HTMLInputElement>) => {
if (lockedControls) return;
setTagName(event.target.value);
});

const handleTagDescChange = useEventCallback((event: React.ChangeEvent<HTMLInputElement>) => {
if (lockedControls) return;
setTagDescription(event.target.value);
});

const handleTagClick = useEventCallback((event: React.MouseEvent<HTMLElement>) => {
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 (
<>
<Button key="add-cancel" onClick={handleCancel} buttonType="secondary" className="px-6 py-2">Cancel</Button>
<Button
key="add-confirm"
onClick={handleCreate}
buttonType="primary"
disabled={!canCreate}
className="px-6 py-2"
>
Create
</Button>
</>
);
}
if (mode === 'edit') {
return (
<>
<Button
key="edit-delete"
onClick={handleDelete}
buttonType="secondary"
disabled={!selectedTag}
className="px-6 py-2"
>
Delete
</Button>
<Button key="edit-cancel" onClick={handleCancel} buttonType="secondary" className="px-6 py-2">Cancel</Button>
<Button key="edit-save" onClick={handleSave} buttonType="primary" disabled={!changed} className="px-6 py-2">
Save
</Button>
</>
);
}
if (selectedTag && activeTagSet.has(selectedTag.ID)) {
return (
<>
<Button key="remove-cancel" onClick={handleCancel} buttonType="secondary" className="px-6 py-2">
Cancel
</Button>
<Button key="remove" onClick={handleRemove} buttonType="danger" disabled={!selectedTag} className="px-6 py-2">
Remove
</Button>
</>
);
}
if (selectedTag) {
return (
<>
<Button key="add-cancel" onClick={handleCancel} buttonType="secondary" className="px-6 py-2">Cancel</Button>
<Button key="add" onClick={handleAdd} buttonType="primary" disabled={!selectedTag} className="px-6 py-2">
Add
</Button>
</>
);
}
return <Button key="cancel" onClick={handleCancel} buttonType="secondary" className="px-6 py-2">Close</Button>;
}, [
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 (
<ModalPanel
show={show}
onRequestClose={handleClose}
header="Custom Tags"
size="sm"
overlayClassName="!z-[90]"
subHeader={subHeader}
>
<div className="flex grow flex-col gap-y-2">
<div className="flex justify-between">
<div className="mb-2 font-semibold">
Available Tags
</div>
<div className="flex gap-x-2">
<Button onClick={handleEditModeToggle} disabled={!!mode} tooltip="Edit Tags">
<Icon className="text-panel-icon-action" path={mdiPencilCircleOutline} size={1} />
</Button>
<Button onClick={handleCreateModeToggle} disabled={!!mode} tooltip="Create Tag">
<Icon className="text-panel-icon-action" path={mdiPlusCircleOutline} size={1} />
</Button>
</div>
</div>
<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">
{userTagsQuery.data?.map(tag => (
<div
key={tag.ID}
data-tag-id={tag.ID}
onClick={handleTagClick}
className={cx(
'flex flex-row justify-between',
lockedTag && 'opacity-65',
!lockedTag && 'cursor-pointer',
!lockedTag && activeTagSet.has(tag.ID) && (!selectedTag || selectedTag.ID !== tag.ID)
&& 'text-panel-text-primary',
selectedTag?.ID === tag.ID && 'text-panel-text-important',
)}
>
<span>
{tag.Name}
</span>
<span className="w-10 text-center">
{tag.Size ?? 0}
</span>
</div>
))}
</div>
</div>
<div>
<div className="mb-2 font-semibold">
Name
</div>
<Input
id="tag-name"
type="text"
disabled={lockedControls}
value={tagName}
onChange={handleTagNameChange}
className={cx(
lockedControls && 'opacity-65',
)}
/>
</div>
<div>
<div className="mb-2 font-semibold">
Description
</div>
<Input
id="tag-desc"
type="text"
disabled={lockedControls}
value={tagDesc}
onChange={handleTagDescChange}
className={cx(
lockedControls && 'opacity-65',
)}
/>
</div>
<div className="flex justify-end gap-x-3 font-semibold">
{buttons}
</div>
</ModalPanel>
);
}

export default CustomTagModal;
Loading
Loading