feat(ui): add ability to archive/unarchive boards, add toggle to gallery settings to show/hide archived boards in list

This commit is contained in:
Mary Hipp 2024-06-26 14:04:10 -04:00 committed by psychedelicious
parent 5120a76ce5
commit 68c0aa898f
16 changed files with 404 additions and 170 deletions

View File

@ -17,6 +17,8 @@
},
"boards": {
"addBoard": "Add Board",
"archiveBoard": "Archive Board",
"archived": "Archived",
"autoAddBoard": "Auto-Add Board",
"bottomMessage": "Deleting this board and its images will reset any features currently using them.",
"cancel": "Cancel",
@ -36,6 +38,7 @@
"searchBoard": "Search Boards...",
"selectBoard": "Select a Board",
"topMessage": "This board contains images used in the following features:",
"unarchiveBoard": "Unarchive Board",
"uncategorized": "Uncategorized",
"downloadBoard": "Download Board",
"imagesWithCount_one": "{{count}} image",
@ -387,6 +390,7 @@
"openInViewer": "Open in Viewer",
"selectAllOnPage": "Select All On Page",
"selectAllOnBoard": "Select All On Board",
"showArchivedBoards": "Show Archived Boards",
"selectForCompare": "Select for Compare",
"selectAnImageToCompare": "Select an Image to Compare",
"slider": "Slider",

View File

@ -11,6 +11,7 @@ import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { useAddImagesToBoardMutation, useRemoveImagesFromBoardMutation } from 'services/api/endpoints/images';
import { selectListBoardsQueryArgs } from '../../gallery/store/gallerySelectors';
const selectImagesToChange = createMemoizedSelector(
selectChangeBoardModalSlice,
@ -20,7 +21,8 @@ const selectImagesToChange = createMemoizedSelector(
const ChangeBoardModal = () => {
const dispatch = useAppDispatch();
const [selectedBoard, setSelectedBoard] = useState<string | null>();
const { data: boards, isFetching } = useListAllBoardsQuery();
const queryArgs = useAppSelector(selectListBoardsQueryArgs);
const { data: boards, isFetching } = useListAllBoardsQuery(queryArgs);
const isModalOpen = useAppSelector((s) => s.changeBoardModal.isModalOpen);
const imagesToChange = useAppSelector(selectImagesToChange);
const [addImagesToBoard] = useAddImagesToBoardMutation();

View File

@ -11,25 +11,28 @@ const BoardAutoAddSelect = () => {
const { t } = useTranslation();
const autoAddBoardId = useAppSelector((s) => s.gallery.autoAddBoardId);
const autoAssignBoardOnClick = useAppSelector((s) => s.gallery.autoAssignBoardOnClick);
const { options, hasBoards } = useListAllBoardsQuery(undefined, {
selectFromResult: ({ data }) => {
const options: ComboboxOption[] = [
{
label: t('controlnet.none'),
value: 'none',
},
].concat(
(data ?? []).map(({ board_id, board_name }) => ({
label: board_name,
value: board_id,
}))
);
return {
options,
hasBoards: options.length > 1,
};
},
});
const { options, hasBoards } = useListAllBoardsQuery(
{},
{
selectFromResult: ({ data }) => {
const options: ComboboxOption[] = [
{
label: t('controlnet.none'),
value: 'none',
},
].concat(
(data ?? []).map(({ board_id, board_name }) => ({
label: board_name,
value: board_id,
}))
);
return {
options,
hasBoards: options.length > 1,
};
},
}
);
const onChange = useCallback<ComboboxOnChange>(
(v) => {

View File

@ -7,12 +7,14 @@ import type { BoardId } from 'features/gallery/store/types';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiDownloadBold, PiPlusBold } from 'react-icons/pi';
import { PiArchiveBold, PiArchiveFill, PiDownloadBold, PiPlusBold } from 'react-icons/pi';
import { useBulkDownloadImagesMutation } from 'services/api/endpoints/images';
import { useBoardName } from 'services/api/hooks/useBoardName';
import type { BoardDTO } from 'services/api/types';
import GalleryBoardContextMenuItems from './GalleryBoardContextMenuItems';
import { useUpdateBoardMutation } from '../../../../services/api/endpoints/boards';
import { MdArchive, MdUnarchive } from 'react-icons/md';
type Props = {
board?: BoardDTO;
@ -30,6 +32,8 @@ const BoardContextMenu = ({ board, board_id, setBoardToDelete, children }: Props
[board]
);
const [updateBoard] = useUpdateBoardMutation();
const isSelectedForAutoAdd = useAppSelector(selectIsSelectedForAutoAdd);
const boardName = useBoardName(board_id);
const isBulkDownloadEnabled = useFeatureStatus('bulkDownload');
@ -44,13 +48,31 @@ const BoardContextMenu = ({ board, board_id, setBoardToDelete, children }: Props
bulkDownload({ image_names: [], board_id: board_id });
}, [board_id, bulkDownload]);
const handleArchive = useCallback(() => {
updateBoard({
board_id,
changes: { archived: true },
});
}, [board_id, updateBoard]);
const handleUnarchive = useCallback(() => {
updateBoard({
board_id,
changes: { archived: false },
});
}, [board_id, updateBoard]);
const isBoardArchived = useMemo(() => {
return !!board?.archived;
}, [board]);
const renderMenuFunc = useCallback(
() => (
<MenuList visibility="visible">
<MenuGroup title={boardName}>
<MenuItem
icon={<PiPlusBold />}
isDisabled={isSelectedForAutoAdd || autoAssignBoardOnClick}
isDisabled={isSelectedForAutoAdd || autoAssignBoardOnClick || isBoardArchived}
onClick={handleSetAutoAdd}
>
{t('boards.menuItemAutoAdd')}
@ -60,6 +82,17 @@ const BoardContextMenu = ({ board, board_id, setBoardToDelete, children }: Props
{t('boards.downloadBoard')}
</MenuItem>
)}
{board &&
(isBoardArchived ? (
<MenuItem icon={<PiArchiveBold />} onClick={handleUnarchive}>
{t('boards.unarchiveBoard')}
</MenuItem>
) : (
<MenuItem icon={<PiArchiveFill />} onClick={handleArchive}>
{t('boards.archiveBoard')}
</MenuItem>
))}
{board && <GalleryBoardContextMenuItems board={board} setBoardToDelete={setBoardToDelete} />}
</MenuGroup>
</MenuList>

View File

@ -3,9 +3,10 @@ import { useGetBoardAssetsTotalQuery, useGetBoardImagesTotalQuery } from 'servic
type Props = {
board_id: string;
isArchived: boolean;
};
export const BoardTotalsTooltip = ({ board_id }: Props) => {
export const BoardTotalsTooltip = ({ board_id, isArchived }: Props) => {
const { t } = useTranslation();
const { imagesTotal } = useGetBoardImagesTotalQuery(board_id, {
selectFromResult: ({ data }) => {
@ -17,5 +18,5 @@ export const BoardTotalsTooltip = ({ board_id }: Props) => {
return { assetsTotal: data?.total ?? 0 };
},
});
return `${t('boards.imagesWithCount', { count: imagesTotal })}, ${t('boards.assetsWithCount', { count: assetsTotal })}`;
return `${t('boards.imagesWithCount', { count: imagesTotal })}, ${t('boards.assetsWithCount', { count: assetsTotal })}${isArchived ? ` (${t('boards.archived')})` : ''}`;
};

View File

@ -12,6 +12,7 @@ import AddBoardButton from './AddBoardButton';
import BoardsSearch from './BoardsSearch';
import GalleryBoard from './GalleryBoard';
import NoBoardBoard from './NoBoardBoard';
import { selectListBoardsQueryArgs } from '../../../store/gallerySelectors';
const overlayScrollbarsStyles: CSSProperties = {
height: '100%',
@ -26,7 +27,9 @@ const BoardsList = (props: Props) => {
const { isOpen } = props;
const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId);
const boardSearchText = useAppSelector((s) => s.gallery.boardSearchText);
const { data: boards } = useListAllBoardsQuery();
const queryArgs = useAppSelector(selectListBoardsQueryArgs);
console.log({ queryArgs });
const { data: boards } = useListAllBoardsQuery(queryArgs);
const filteredBoards = boardSearchText
? boards?.filter((board) => board.board_name.toLowerCase().includes(boardSearchText.toLowerCase()))
: boards;

View File

@ -12,7 +12,7 @@ import { BoardTotalsTooltip } from 'features/gallery/components/Boards/BoardsLis
import { autoAddBoardIdChanged, boardIdSelected, selectGallerySlice } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PiImagesSquare } from 'react-icons/pi';
import { PiArchiveBold, PiImagesSquare } from 'react-icons/pi';
import { useUpdateBoardMutation } from 'services/api/endpoints/boards';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import type { BoardDTO } from 'services/api/types';
@ -33,6 +33,7 @@ interface GalleryBoardProps {
const GalleryBoard = ({ board, isSelected, setBoardToDelete }: GalleryBoardProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const autoAssignBoardOnClick = useAppSelector((s) => s.gallery.autoAssignBoardOnClick);
const selectIsSelectedForAutoAdd = useMemo(
() => createSelector(selectGallerySlice, (gallery) => board.board_id === gallery.autoAddBoardId),
@ -103,7 +104,7 @@ const GalleryBoard = ({ board, isSelected, setBoardToDelete }: GalleryBoardProps
const handleChange = useCallback((newBoardName: string) => {
setLocalBoardName(newBoardName);
}, []);
const { t } = useTranslation();
return (
<Box w="full" h="full" userSelect="none">
<Flex
@ -118,7 +119,10 @@ const GalleryBoard = ({ board, isSelected, setBoardToDelete }: GalleryBoardProps
>
<BoardContextMenu board={board} board_id={board_id} setBoardToDelete={setBoardToDelete}>
{(ref) => (
<Tooltip label={<BoardTotalsTooltip board_id={board.board_id} />} openDelay={1000}>
<Tooltip
label={<BoardTotalsTooltip board_id={board.board_id} isArchived={Boolean(board.archived)} />}
openDelay={1000}
>
<Flex
ref={ref}
onClick={handleSelectBoard}
@ -131,6 +135,25 @@ const GalleryBoard = ({ board, isSelected, setBoardToDelete }: GalleryBoardProps
cursor="pointer"
bg="base.800"
>
{board.archived && (
<Box
sx={{
position: 'absolute',
top: 1,
insetInlineEnd: 2,
p: 0,
minW: 0,
svg: {
transitionProperty: 'common',
transitionDuration: 'normal',
fill: 'base.300',
filter: 'drop-shadow(0px 0px 0.1rem var(--invoke-colors-base-800))',
},
}}
>
<Icon as={PiArchiveBold} />
</Box>
)}
{coverImage?.thumbnail_url ? (
<Image
src={coverImage?.thumbnail_url}

View File

@ -19,6 +19,7 @@ import {
autoAssignBoardOnClickChanged,
setGalleryImageMinimumWidth,
shouldAutoSwitchChanged,
shouldShowArchivedBoardsChanged,
} from 'features/gallery/store/gallerySlice';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
@ -38,6 +39,7 @@ const GallerySettingsPopover = () => {
const shouldAutoSwitch = useAppSelector((s) => s.gallery.shouldAutoSwitch);
const autoAssignBoardOnClick = useAppSelector((s) => s.gallery.autoAssignBoardOnClick);
const alwaysShowImageSizeBadge = useAppSelector((s) => s.gallery.alwaysShowImageSizeBadge);
const shouldShowArchivedBoards = useAppSelector((s) => s.gallery.shouldShowArchivedBoards);
const handleChangeGalleryImageMinimumWidth = useCallback(
(v: number) => {
@ -63,6 +65,11 @@ const GallerySettingsPopover = () => {
[dispatch]
);
const handleChangeShouldShowArchivedBoardsChanged = useCallback(
(e: ChangeEvent<HTMLInputElement>) => dispatch(shouldShowArchivedBoardsChanged(e.target.checked)),
[dispatch]
);
return (
<Popover isLazy>
<PopoverTrigger>
@ -99,6 +106,10 @@ const GallerySettingsPopover = () => {
<FormLabel>{t('gallery.alwaysShowImageSizeBadge')}</FormLabel>
<Checkbox isChecked={alwaysShowImageSizeBadge} onChange={handleChangeAlwaysShowImageSizeBadgeChanged} />
</FormControl>
<FormControl>
<FormLabel>{t('gallery.showArchivedBoards')}</FormLabel>
<Checkbox isChecked={shouldShowArchivedBoards} onChange={handleChangeShouldShowArchivedBoardsChanged} />
</FormControl>
</FormControlGroup>
<BoardAutoAddSelect />
</Flex>

View File

@ -3,7 +3,7 @@ import { skipToken } from '@reduxjs/toolkit/query';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { selectGallerySlice } from 'features/gallery/store/gallerySlice';
import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types';
import type { ListImagesArgs } from 'services/api/types';
import type { ListBoardsArgs, ListImagesArgs } from 'services/api/types';
export const selectLastSelectedImage = createMemoizedSelector(
selectGallerySlice,
@ -23,3 +23,10 @@ export const selectListImagesQueryArgs = createMemoizedSelector(
}
: skipToken
);
export const selectListBoardsQueryArgs = createMemoizedSelector(
selectGallerySlice,
(gallery): ListBoardsArgs => ({
archived: gallery.shouldShowArchivedBoards ? true : undefined
})
);

View File

@ -25,6 +25,7 @@ const initialGalleryState: GalleryState = {
imageToCompare: null,
comparisonMode: 'slider',
comparisonFit: 'fill',
shouldShowArchivedBoards: false,
};
export const gallerySlice = createSlice({
@ -110,6 +111,9 @@ export const gallerySlice = createSlice({
limitChanged: (state, action: PayloadAction<number>) => {
state.limit = action.payload;
},
shouldShowArchivedBoardsChanged: (state, action: PayloadAction<boolean>) => {
state.shouldShowArchivedBoards = action.payload;
},
},
extraReducers: (builder) => {
builder.addMatcher(isAnyBoardDeleted, (state, action) => {
@ -154,6 +158,7 @@ export const {
comparisonModeCycled,
offsetChanged,
limitChanged,
shouldShowArchivedBoardsChanged,
} = gallerySlice.actions;
const isAnyBoardDeleted = isAnyOf(

View File

@ -25,4 +25,5 @@ export type GalleryState = {
comparisonMode: ComparisonMode;
comparisonFit: ComparisonFit;
isImageViewerOpen: boolean;
shouldShowArchivedBoards: boolean;
};

View File

@ -1,6 +1,6 @@
import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
import { Combobox, FormControl } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { fieldBoardValueChanged } from 'features/nodes/store/nodesSlice';
import type { BoardFieldInputInstance, BoardFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback, useMemo } from 'react';
@ -8,12 +8,14 @@ import { useTranslation } from 'react-i18next';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import type { FieldComponentProps } from './types';
import { selectListBoardsQueryArgs } from '../../../../../../../gallery/store/gallerySelectors';
const BoardFieldInputComponent = (props: FieldComponentProps<BoardFieldInputInstance, BoardFieldInputTemplate>) => {
const { nodeId, field } = props;
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { options, hasBoards } = useListAllBoardsQuery(undefined, {
const queryArgs = useAppSelector(selectListBoardsQueryArgs);
const { options, hasBoards } = useListAllBoardsQuery(queryArgs, {
selectFromResult: ({ data }) => {
const options: ComboboxOption[] = [
{

View File

@ -1,5 +1,5 @@
import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types';
import type { BoardDTO, OffsetPaginatedResults_ImageDTO_, UpdateBoardArg } from 'services/api/types';
import type { BoardDTO, ListBoardsArgs, OffsetPaginatedResults_ImageDTO_, UpdateBoardArg } from 'services/api/types';
import { getListImagesUrl } from 'services/api/util';
import type { ApiTagDescription } from '..';
@ -18,10 +18,10 @@ export const boardsApi = api.injectEndpoints({
/**
* Boards Queries
*/
listAllBoards: build.query<Array<BoardDTO>, void>({
query: () => ({
listAllBoards: build.query<Array<BoardDTO>, ListBoardsArgs>({
query: (args) => ({
url: buildBoardsUrl(),
params: { all: true },
params: { all: true, ...args },
}),
providesTags: (result) => {
// any list of boards

View File

@ -1,9 +1,10 @@
import type { BoardId } from 'features/gallery/store/types';
import { t } from 'i18next';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { selectListBoardsQueryArgs } from '../../../features/gallery/store/gallerySelectors';
export const useBoardName = (board_id: BoardId) => {
const { boardName } = useListAllBoardsQuery(undefined, {
const { boardName } = useListAllBoardsQuery({}, {
selectFromResult: ({ data }) => {
const selectedBoard = data?.find((b) => b.board_id === board_id);
const boardName = selectedBoard?.board_name || t('boards.uncategorized');

File diff suppressed because one or more lines are too long

View File

@ -6,6 +6,8 @@ export type S = components['schemas'];
export type ListImagesArgs = NonNullable<paths['/api/v1/images/']['get']['parameters']['query']>;
export type ListImagesResponse = paths['/api/v1/images/']['get']['responses']['200']['content']['application/json'];
export type ListBoardsArgs = NonNullable<paths['/api/v1/boards/']['get']['parameters']['query']>;
export type DeleteBoardResult =
paths['/api/v1/boards/{board_id}']['delete']['responses']['200']['content']['application/json'];