Skip to content

Commit

Permalink
Feat: Add edit group modal (#935)
Browse files Browse the repository at this point in the history
* Feat: Add edit group modal

* Misc: Memoization of GroupEditModal tabs

* Misc: Avoid padding on list items without overflowing content

* Misc: Optionally apply consistent result sorting to GroupSeriesQuery

* Misc: Implement optional sorting sensibly...
  • Loading branch information
fearnlj01 authored Jun 12, 2024
1 parent a094b75 commit e0de333
Show file tree
Hide file tree
Showing 10 changed files with 411 additions and 30 deletions.
75 changes: 75 additions & 0 deletions src/components/Collection/Group/EditGroupModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import cx from 'classnames';
import { map } from 'lodash';

import NameTab from '@/components/Collection/Group/EditGroupTabs/NameTab';
import SeriesTab from '@/components/Collection/Group/EditGroupTabs/SeriesTab';
import ModalPanel from '@/components/Panels/ModalPanel';
import { setGroupId } from '@/core/slices/modals/editGroup';
import useEventCallback from '@/hooks/useEventCallback';

import type { RootState } from '@/core/store';

const tabs = {
name: 'Name',
series: 'Series',
};

const renderTab = (activeTab: string, groupId: number) => {
if (groupId === -1) {
return null;
}

switch (activeTab) {
case 'series':
return <SeriesTab groupId={groupId} />;
case 'name':
default:
return <NameTab groupId={groupId} />;
}
};

const EditGroupModal = () => {
const dispatch = useDispatch();

const onClose = useEventCallback(() => {
dispatch(setGroupId(-1));
});

useEffect(() => onClose, [onClose]);

const groupId = useSelector((state: RootState) => state.modals.editGroup.groupId);

const [activeTab, setActiveTab] = useState<keyof typeof tabs>('name');

return (
<ModalPanel show={groupId !== -1} onRequestClose={onClose} header="Edit Group" size="md" noPadding noGap>
<div className="flex h-[26rem] flex-row gap-x-6 p-6">
<div className="flex shrink-0 gap-y-6 font-semibold">
<div className="flex flex-col gap-y-1">
{map(tabs, (value: string, key: keyof typeof tabs) => (
<div
className={cx(
activeTab === key
? 'w-[12rem] text-center bg-panel-menu-item-background p-3 rounded-lg text-panel-menu-item-text cursor-pointer'
: 'w-[12rem] text-center p-3 rounded-lg hover:bg-panel-menu-item-background-hover cursor-pointer',
)}
key={key}
onClick={() => setActiveTab(key)}
>
{value}
</div>
))}
</div>
</div>
<div className="border-r border-panel-border" />
<div className="grow">
{renderTab(activeTab, groupId)}
</div>
</div>
</ModalPanel>
);
};

export default EditGroupModal;
138 changes: 138 additions & 0 deletions src/components/Collection/Group/EditGroupTabs/NameTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import React, { useEffect, useMemo, useState } from 'react';
import { mdiCheckUnderlineCircleOutline, mdiCloseCircleOutline, mdiPencilCircleOutline } from '@mdi/js';
import cx from 'classnames';
import { useToggle } from 'usehooks-ts';

import Input from '@/components/Input/Input';
import { usePatchGroupMutation } from '@/core/react-query/group/mutations';
import { useGroupQuery, useGroupSeriesQuery } from '@/core/react-query/group/queries';
import useEventCallback from '@/hooks/useEventCallback';

type Props = {
groupId: number;
};

const NameTab = React.memo(({ groupId }: Props) => {
const {
data: groupData,
isError: groupError,
isFetching: groupFetching,
isSuccess: groupSuccess,
} = useGroupQuery(groupId);
const {
data: seriesData,
isError: seriesError,
isFetching: seriesFetching,
isSuccess: seriesSuccess,
} = useGroupSeriesQuery(groupId);

const [groupName, setGroupName] = useState(groupData?.Name ?? '');
useEffect(() => {
setGroupName(groupData?.Name ?? '');
}, [groupData?.Name]);

const [nameEditable, toggleNameEditable] = useToggle(false);

const { mutate: renameGroupMutation } = usePatchGroupMutation();
const renameGroup = useEventCallback(() => {
if (groupName !== groupData?.Name) {
renameGroupMutation(
{
seriesId: 0, // Hack to avoid creating a new mutation...
groupId,
operations: [
{ op: 'replace', path: 'Name', value: groupName },
{ op: 'replace', path: 'HasCustomName', value: 'true' },
],
},
);
}
toggleNameEditable();
});

const updateName = useEventCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setGroupName(e.target.value);
});

const resetName = useEventCallback(() => {
toggleNameEditable();
setGroupName(groupData?.Name ?? '');
});

const nameInputIcons = useMemo(() => {
if (!nameEditable || groupFetching || seriesFetching) {
return [{
icon: mdiPencilCircleOutline,
className: 'text-panel-text-primary',
onClick: toggleNameEditable,
tooltip: 'Edit name',
}];
}

if (!(groupSuccess && seriesSuccess)) return [];

return [
{
icon: mdiCloseCircleOutline,
className: 'text-panel-text-danger',
onClick: resetName,
tooltip: 'Cancel',
},
{
icon: mdiCheckUnderlineCircleOutline,
className: 'text-panel-text-success',
onClick: renameGroup,
tooltip: 'Confirm',
},
];
}, [
groupFetching,
groupSuccess,
nameEditable,
renameGroup,
resetName,
seriesFetching,
seriesSuccess,
toggleNameEditable,
]);

return (
<div className="flex h-full flex-col">
{(groupError || seriesError) && (
<div className="m-auto text-lg font-semibold text-panel-text-danger">
Group & Series data could not be loaded!
</div>
)}

<Input
id="group-name"
type="text"
onChange={updateName}
value={groupName}
placeholder={(groupFetching || seriesFetching) ? 'Loading...' : undefined}
label="Name"
className="mb-4"
inputClassName={cx(nameInputIcons.length > 1 ? 'pr-[5rem]' : 'pr-12', 'truncate')}
endIcons={nameInputIcons}
disabled={!nameEditable}
/>
{nameEditable && (
<div className="flex cursor-pointer overflow-y-auto rounded-lg border border-panel-border bg-panel-input p-6">
<div className="shoko-scrollbar flex grow flex-col gap-y-2 overflow-y-auto bg-panel-input pr-4">
{seriesData?.map(series => (
<div
className="flex justify-between last:border-none hover:text-panel-text-primary"
key={series.IDs.ID}
onClick={() => setGroupName(series.Name)}
>
<div>{series.Name}</div>
</div>
))}
</div>
</div>
)}
</div>
);
});

export default NameTab;
124 changes: 124 additions & 0 deletions src/components/Collection/Group/EditGroupTabs/SeriesTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import React, { useMemo } from 'react';
import type { PlacesType } from 'react-tooltip';
import { mdiArrowRightThinCircleOutline, mdiLoading, mdiStarCircleOutline } from '@mdi/js';
import Icon from '@mdi/react';
import cx from 'classnames';

import { useCreateGroupMutation, usePatchGroupMutation } from '@/core/react-query/group/mutations';
import { useGroupQuery, useGroupSeriesQuery } from '@/core/react-query/group/queries';
import { invalidateQueries } from '@/core/react-query/queryClient';
import useEventCallback from '@/hooks/useEventCallback';

type Props = {
groupId: number;
};

const SeriesTab = React.memo(({ groupId }: Props) => {
const {
data: groupData,
isError: groupError,
isPending: groupPending,
isSuccess: groupSuccess,
} = useGroupQuery(groupId);
const {
data: seriesData,
isError: seriesError,
isPending: seriesPending,
isSuccess: seriesSuccess,
} = useGroupSeriesQuery(groupId);

const sortedSeriesData = useMemo(() => seriesData?.sort((a, b) => (a.IDs.ID > b.IDs.ID ? 1 : -1)), [seriesData]);

const { mutate: moveToNewGroupMutation } = useCreateGroupMutation();
const { mutate: setGroupMainSeriesMutation } = usePatchGroupMutation();

const moveSeriesToNewGroup = useEventCallback((seriesId: number) => {
moveToNewGroupMutation(seriesId, {
onSuccess: () => {
invalidateQueries(['group', groupId]);
invalidateQueries(['group-series', groupId]);
},
});
});

const setMainSeries = useEventCallback((seriesId: number) => {
if (groupData!.IDs.MainSeries !== seriesId) {
setGroupMainSeriesMutation({
groupId,
seriesId,
operations: [{ op: 'replace', path: 'PreferredSeriesID', value: seriesId }],
}, {
onSuccess: () => {
invalidateQueries(['group', groupId]);
invalidateQueries(['group-series', groupId]);
},
});
}
});

return (
<div className="flex h-full flex-col">
<div className="flex overflow-y-auto rounded-lg border border-panel-border bg-panel-input p-6">
<div
className={cx(
'shoko-scrollbar flex grow flex-col gap-y-2 overflow-y-auto bg-panel-input',
(seriesSuccess && seriesData.length > 9) && 'pr-4',
)}
>
{(seriesPending || groupPending) && (
<Icon
path={mdiLoading}
size={3}
className="my-auto self-center text-panel-text-primary"
spin
/>
)}
{(seriesError || groupError) && (
<span className="my-auto self-center text-panel-text-danger">Error, please refresh!</span>
)}
{(seriesSuccess && groupSuccess) && sortedSeriesData?.map((series) => {
const isMainSeries = series.IDs.ID === groupData?.IDs.MainSeries;

const baseTooltipAttribs = {
'data-tooltip-id': 'tooltip',
'data-tooltip-place': 'top' as PlacesType,
'data-tooltip-delay-show': 500,
};
const mainSeriesTooltip = isMainSeries
? {}
: { ...baseTooltipAttribs, 'data-tooltip-content': 'Set as main series' };
const moveToNewGroupTooltip = { ...baseTooltipAttribs, 'data-tooltip-content': 'Move series to new group' };

return (
<div
className="flex justify-between gap-x-2 last:border-none hover:text-panel-text-primary"
key={series.IDs.ID}
>
<div className="grow select-none">{series.Name}</div>
<div
className={cx(
'shrink-0',
isMainSeries ? 'text-panel-icon-warning opacity-65' : 'text-panel-icon-action cursor-pointer',
)}
{...mainSeriesTooltip}
onClick={() => setMainSeries(series.IDs.ID)}
>
<Icon path={mdiStarCircleOutline} size={1} />
</div>
<div
className="shrink-0 cursor-pointer text-panel-icon-action"
{...moveToNewGroupTooltip}
onClick={() => moveSeriesToNewGroup(series.IDs.ID)}
>
<Icon path={mdiArrowRightThinCircleOutline} size={1} />
</div>
</div>
);
})}
</div>
</div>
</div>
);
});

export default SeriesTab;
29 changes: 17 additions & 12 deletions src/components/Collection/ListViewItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import BackgroundImagePlaceholderDiv from '@/components/BackgroundImagePlacehold
import { listItemSize } from '@/components/Collection/constants';
import { useSeriesTagsQuery } from '@/core/react-query/series/queries';
import { useSettingsQuery } from '@/core/react-query/settings/queries';
import { setGroupId } from '@/core/slices/modals/editGroup';
import { setSeriesId } from '@/core/slices/modals/editSeries';
import { dayjs, formatThousand } from '@/core/util';
import useEventCallback from '@/hooks/useEventCallback';
Expand Down Expand Up @@ -131,6 +132,12 @@ const ListViewItem = ({ groupExtras, isSeries, isSidebarOpen, item }: Props) =>
dispatch(setSeriesId(('MainSeries' in item.IDs) ? item.IDs.MainSeries : item.IDs.ID));
});

const editGroupModalCallback = useEventCallback((event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
dispatch(setGroupId(item.IDs.ParentGroup ?? item.IDs.TopLevelGroup));
});

return (
<div
className="flex h-full shrink-0 grow flex-col content-center gap-y-3 rounded-lg border border-panel-border bg-panel-background p-6"
Expand All @@ -147,18 +154,16 @@ const ListViewItem = ({ groupExtras, isSeries, isSidebarOpen, item }: Props) =>
zoomOnHover
>
<div className="pointer-events-none z-10 flex h-full bg-panel-background-poster-overlay p-3 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
{(isSeries || item.Size === 1) && (
<div
className="pointer-events-auto h-fit"
onClick={editSeriesModalCallback}
>
<Icon
path={mdiPencilCircleOutline}
size="2rem"
className="text-panel-icon"
/>
</div>
)}
<div
className="pointer-events-auto h-fit"
onClick={(isSeries || item.Size === 1) ? editSeriesModalCallback : editGroupModalCallback}
>
<Icon
path={mdiPencilCircleOutline}
size="2rem"
className="text-panel-icon"
/>
</div>
</div>
{showGroupIndicator && groupCount > 1 && (
<div className="absolute bottom-0 left-0 flex w-full justify-center rounded-bl-md bg-panel-background-overlay py-1.5 text-sm font-semibold opacity-100 transition-opacity group-hover:opacity-0">
Expand Down
Loading

0 comments on commit e0de333

Please sign in to comment.