Skip to content

Commit

Permalink
feat: add custom tag modal
Browse files Browse the repository at this point in the history
  • Loading branch information
revam committed Jan 20, 2025
1 parent 73171a3 commit c861c19
Show file tree
Hide file tree
Showing 6 changed files with 439 additions and 1 deletion.
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);

const handleEditTagsClickHandler = useEventCallback(() => {
if (!seriesId) return;
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} />
{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

0 comments on commit c861c19

Please sign in to comment.