diff --git a/invokeai/frontend/web/src/app/contexts/AddImageToBoardContext.tsx b/invokeai/frontend/web/src/app/contexts/AddImageToBoardContext.tsx index da3dcb2239..d29c1c8a48 100644 --- a/invokeai/frontend/web/src/app/contexts/AddImageToBoardContext.tsx +++ b/invokeai/frontend/web/src/app/contexts/AddImageToBoardContext.tsx @@ -3,6 +3,7 @@ import { useAppDispatch } from 'app/store/storeHooks'; import { PropsWithChildren, createContext, useCallback, useState } from 'react'; import { ImageDTO } from 'services/api'; import { imageAddedToBoard } from '../../services/thunks/board'; +import { useAddImageToBoardMutation } from 'services/apiSlice'; export type ImageUsage = { isInitialImage: boolean; @@ -43,6 +44,8 @@ export const AddImageToBoardContextProvider = (props: Props) => { const dispatch = useAppDispatch(); const { isOpen, onOpen, onClose } = useDisclosure(); + const [addImageToBoard, result] = useAddImageToBoardMutation(); + // Clean up after deleting or dismissing the modal const closeAndClearImageToDelete = useCallback(() => { setImageToMove(undefined); @@ -63,18 +66,14 @@ export const AddImageToBoardContextProvider = (props: Props) => { const handleAddToBoard = useCallback( (boardId: string) => { if (imageToMove) { - dispatch( - imageAddedToBoard({ - requestBody: { - board_id: boardId, - image_name: imageToMove.image_name, - }, - }) - ); + addImageToBoard({ + board_id: boardId, + image_name: imageToMove.image_name, + }); closeAndClearImageToDelete(); } }, - [closeAndClearImageToDelete, dispatch, imageToMove] + [addImageToBoard, closeAndClearImageToDelete, imageToMove] ); return ( diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index 8c073e81d6..15fd48fbb2 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -73,6 +73,10 @@ import { addImageCategoriesChangedListener } from './listeners/imageCategoriesCh import { addControlNetImageProcessedListener } from './listeners/controlNetImageProcessed'; import { addControlNetAutoProcessListener } from './listeners/controlNetAutoProcess'; import { addUpdateImageUrlsOnConnectListener } from './listeners/updateImageUrlsOnConnect'; +import { + addImageAddedToBoardFulfilledListener, + addImageAddedToBoardRejectedListener, +} from './listeners/imageAddedToBoard'; export const listenerMiddleware = createListenerMiddleware(); @@ -183,3 +187,7 @@ addControlNetAutoProcessListener(); // Update image URLs on connect addUpdateImageUrlsOnConnectListener(); + +// Boards +addImageAddedToBoardFulfilledListener(); +addImageAddedToBoardRejectedListener(); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard.ts new file mode 100644 index 0000000000..0f404cab68 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard.ts @@ -0,0 +1,40 @@ +import { log } from 'app/logging/useLogger'; +import { startAppListening } from '..'; +import { imageMetadataReceived } from 'services/thunks/image'; +import { api } from 'services/apiSlice'; + +const moduleLog = log.child({ namespace: 'boards' }); + +export const addImageAddedToBoardFulfilledListener = () => { + startAppListening({ + matcher: api.endpoints.addImageToBoard.matchFulfilled, + effect: (action, { getState, dispatch }) => { + const { board_id, image_name } = action.meta.arg.originalArgs; + + moduleLog.debug( + { data: { board_id, image_name } }, + 'Image added to board' + ); + + dispatch( + imageMetadataReceived({ + imageName: image_name, + }) + ); + }, + }); +}; + +export const addImageAddedToBoardRejectedListener = () => { + startAppListening({ + matcher: api.endpoints.addImageToBoard.matchRejected, + effect: (action, { getState, dispatch }) => { + const { board_id, image_name } = action.meta.arg.originalArgs; + + moduleLog.debug( + { data: { board_id, image_name } }, + 'Problem adding image to board' + ); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts index 4c0c057242..9792137bbe 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts @@ -13,6 +13,7 @@ import { resetCanvas } from 'features/canvas/store/canvasSlice'; import { controlNetReset } from 'features/controlNet/store/controlNetSlice'; import { clearInitialImage } from 'features/parameters/store/generationSlice'; import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; +import { api } from 'services/apiSlice'; const moduleLog = log.child({ namespace: 'addRequestedImageDeletionListener' }); @@ -22,7 +23,7 @@ const moduleLog = log.child({ namespace: 'addRequestedImageDeletionListener' }); export const addRequestedImageDeletionListener = () => { startAppListening({ actionCreator: requestedImageDeletion, - effect: (action, { dispatch, getState }) => { + effect: async (action, { dispatch, getState, condition }) => { const { image, imageUsage } = action.payload; const { image_name } = image; @@ -30,7 +31,7 @@ export const addRequestedImageDeletionListener = () => { const state = getState(); const selectedImage = state.gallery.selectedImage; - if (selectedImage && selectedImage.image_name === image_name) { + if (selectedImage && selectedImage === image_name) { const ids = selectImagesIds(state); const entities = selectImagesEntities(state); @@ -51,7 +52,7 @@ export const addRequestedImageDeletionListener = () => { const newSelectedImage = entities[newSelectedImageId]; if (newSelectedImageId) { - dispatch(imageSelected(newSelectedImage)); + dispatch(imageSelected(newSelectedImageId)); } else { dispatch(imageSelected()); } @@ -79,7 +80,19 @@ export const addRequestedImageDeletionListener = () => { dispatch(imageRemoved(image_name)); // Delete from server - dispatch(imageDeleted({ imageName: image_name })); + const { requestId } = dispatch(imageDeleted({ imageName: image_name })); + + // Wait for successful deletion, then trigger boards to re-fetch + const wasImageDeleted = await condition( + (action) => action.meta.requestId === requestId, + 30000 + ); + + if (wasImageDeleted) { + dispatch( + api.util.invalidateTags([{ type: 'Board', id: image.board_id }]) + ); + } }, }); }; diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index 4032db3159..a9011f9356 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -33,6 +33,7 @@ import { actionsDenylist } from './middleware/devtools/actionsDenylist'; import { serialize } from './enhancers/reduxRemember/serialize'; import { unserialize } from './enhancers/reduxRemember/unserialize'; import { LOCALSTORAGE_PREFIX } from './constants'; +import { api } from 'services/apiSlice'; const allReducers = { canvas: canvasReducer, @@ -49,6 +50,7 @@ const allReducers = { images: imagesReducer, controlNet: controlNetReducer, boards: boardsReducer, + [api.reducerPath]: api.reducer, // session: sessionReducer, }; @@ -87,6 +89,7 @@ export const store = configureStore({ immutableCheck: false, serializableCheck: false, }) + .concat(api.middleware) .concat(dynamicMiddlewares) .prepend(listenerMiddleware.middleware), devTools: { diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/AddBoardButton.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/AddBoardButton.tsx index d8828fe736..284e6558ac 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/AddBoardButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/AddBoardButton.tsx @@ -1,19 +1,20 @@ -import { Flex, Icon, Text } from '@chakra-ui/react'; +import { Flex, Icon, Spinner, Text } from '@chakra-ui/react'; import { useCallback } from 'react'; import { FaPlus } from 'react-icons/fa'; -import { useAppDispatch } from '../../../../app/store/storeHooks'; -import { boardCreated } from '../../../../services/thunks/board'; +import { useCreateBoardMutation } from 'services/apiSlice'; + +const DEFAULT_BOARD_NAME = 'My Board'; const AddBoardButton = () => { - const dispatch = useAppDispatch(); + const [createBoard, { isLoading }] = useCreateBoardMutation(); const handleCreateBoard = useCallback(() => { - dispatch(boardCreated({ requestBody: 'My Board' })); - }, [dispatch]); + createBoard(DEFAULT_BOARD_NAME); + }, [createBoard]); return ( <Flex - onClick={handleCreateBoard} + onClick={isLoading ? undefined : handleCreateBoard} sx={{ flexDir: 'column', justifyContent: 'space-between', @@ -36,7 +37,11 @@ const AddBoardButton = () => { aspectRatio: '1/1', }} > - <Icon boxSize={8} color="base.700" as={FaPlus} /> + {isLoading ? ( + <Spinner /> + ) : ( + <Icon boxSize={8} color="base.700" as={FaPlus} /> + )} </Flex> <Text sx={{ color: 'base.200', fontSize: 'xs' }}>New Board</Text> </Flex> diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList.tsx index 1f84d3be0e..be849e625e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList.tsx @@ -25,6 +25,7 @@ import { searchBoardsSelector } from '../../store/boardSelectors'; import { useSelector } from 'react-redux'; import IAICollapse from '../../../../common/components/IAICollapse'; import { CloseIcon } from '@chakra-ui/icons'; +import { useListBoardsQuery } from 'services/apiSlice'; const selector = createSelector( [selectBoardsAll, boardsSelector], @@ -40,9 +41,17 @@ const selector = createSelector( const BoardsList = () => { const dispatch = useAppDispatch(); const { selectedBoard, searchText } = useAppSelector(selector); - const filteredBoards = useSelector(searchBoardsSelector); + // const filteredBoards = useSelector(searchBoardsSelector); const { isOpen, onToggle } = useDisclosure(); + const { data } = useListBoardsQuery({ offset: 0, limit: 8 }); + + const filteredBoards = searchText + ? data?.items.filter((board) => + board.board_name.toLowerCase().includes(searchText.toLowerCase()) + ) + : data.items; + const [searchMode, setSearchMode] = useState(false); const handleBoardSearch = (searchTerm: string) => { @@ -100,13 +109,14 @@ const BoardsList = () => { <AllImagesBoard isSelected={!selectedBoard} /> </> )} - {filteredBoards.map((board) => ( - <HoverableBoard - key={board.board_id} - board={board} - isSelected={selectedBoard?.board_id === board.board_id} - /> - ))} + {filteredBoards && + filteredBoards.map((board) => ( + <HoverableBoard + key={board.board_id} + board={board} + isSelected={selectedBoard?.board_id === board.board_id} + /> + ))} </Grid> </OverlayScrollbarsComponent> </> diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/HoverableBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/HoverableBoard.tsx index fdde7528cb..7ae864f55b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/HoverableBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/HoverableBoard.tsx @@ -26,6 +26,10 @@ import IAIDndImage from '../../../../common/components/IAIDndImage'; import { defaultSelectorOptions } from '../../../../app/store/util/defaultMemoizeOptions'; import { createSelector } from '@reduxjs/toolkit'; import { RootState } from '../../../../app/store/store'; +import { + useDeleteBoardMutation, + useUpdateBoardMutation, +} from 'services/apiSlice'; const coverImageSelector = (imageName: string | undefined) => createSelector( @@ -59,19 +63,20 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => { dispatch(boardIdSelected(board_id)); }, [board_id, dispatch]); - const handleDeleteBoard = useCallback(() => { - dispatch(boardDeleted(board_id)); - }, [board_id, dispatch]); + const [updateBoard, { isLoading: isUpdateBoardLoading }] = + useUpdateBoardMutation(); + + const [deleteBoard, { isLoading: isDeleteBoardLoading }] = + useDeleteBoardMutation(); const handleUpdateBoardName = (newBoardName: string) => { - dispatch( - boardUpdated({ - boardId: board_id, - requestBody: { board_name: newBoardName }, - }) - ); + updateBoard({ board_id, changes: { board_name: newBoardName } }); }; + const handleDeleteBoard = useCallback(() => { + deleteBoard(board_id); + }, [board_id, deleteBoard]); + const handleDrop = useCallback( (droppedImage: ImageDTO) => { if (droppedImage.board_id === board_id) { diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/UpdateImageBoardModal.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/UpdateImageBoardModal.tsx index 9136e23e03..edd4d215af 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/UpdateImageBoardModal.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/UpdateImageBoardModal.tsx @@ -9,6 +9,7 @@ import { Divider, Flex, Select, + Spinner, Text, } from '@chakra-ui/react'; import IAIButton from 'common/components/IAIButton'; @@ -19,9 +20,11 @@ import { useSelector } from 'react-redux'; import { selectBoardsAll } from '../../store/boardSlice'; import IAISelect from '../../../../common/components/IAISelect'; import IAIMantineSelect from 'common/components/IAIMantineSelect'; +import { useListAllBoardsQuery } from 'services/apiSlice'; const UpdateImageBoardModal = () => { - const boards = useSelector(selectBoardsAll); + // const boards = useSelector(selectBoardsAll); + const { data: boards, isFetching } = useListAllBoardsQuery(); const { isOpen, onClose, handleAddToBoard, image } = useContext( AddImageToBoardContext ); @@ -29,9 +32,9 @@ const UpdateImageBoardModal = () => { const cancelRef = useRef<HTMLButtonElement>(null); - const currentBoard = boards.filter( + const currentBoard = boards?.find( (board) => board.board_id === image?.board_id - )[0]; + ); return ( <AlertDialog @@ -55,15 +58,19 @@ const UpdateImageBoardModal = () => { <strong>{currentBoard.board_name}</strong> to </Text> )} - <IAIMantineSelect - placeholder="Select Board" - onChange={(v) => setSelectedBoard(v)} - value={selectedBoard} - data={boards.map((board) => ({ - label: board.board_name, - value: board.board_id, - }))} - /> + {isFetching ? ( + <Spinner /> + ) : ( + <IAIMantineSelect + placeholder="Select Board" + onChange={(v) => setSelectedBoard(v)} + value={selectedBoard} + data={(boards ?? []).map((board) => ({ + label: board.board_name, + value: board.board_id, + }))} + /> + )} </Flex> </Box> </AlertDialogBody> @@ -73,7 +80,9 @@ const UpdateImageBoardModal = () => { isDisabled={!selectedBoard} colorScheme="accent" onClick={() => { - if (selectedBoard) handleAddToBoard(selectedBoard); + if (selectedBoard) { + handleAddToBoard(selectedBoard); + } }} ml={3} > diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx index a5eaeb4c71..169a965be0 100644 --- a/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx @@ -51,9 +51,12 @@ import { useAppToaster } from 'app/components/Toaster'; import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice'; import { DeleteImageContext } from 'app/contexts/DeleteImageContext'; import { DeleteImageButton } from './DeleteImageModal'; +import { selectImagesById } from '../store/imagesSlice'; +import { RootState } from 'app/store/store'; const currentImageButtonsSelector = createSelector( [ + (state: RootState) => state, systemSelector, gallerySelector, postprocessingSelector, @@ -61,7 +64,7 @@ const currentImageButtonsSelector = createSelector( lightboxSelector, activeTabNameSelector, ], - (system, gallery, postprocessing, ui, lightbox, activeTabName) => { + (state, system, gallery, postprocessing, ui, lightbox, activeTabName) => { const { isProcessing, isConnected, @@ -81,6 +84,8 @@ const currentImageButtonsSelector = createSelector( shouldShowProgressInViewer, } = ui; + const imageDTO = selectImagesById(state, gallery.selectedImage ?? ''); + const { selectedImage } = gallery; return { @@ -97,10 +102,10 @@ const currentImageButtonsSelector = createSelector( activeTabName, isLightboxOpen, shouldHidePreview, - image: selectedImage, - seed: selectedImage?.metadata?.seed, - prompt: selectedImage?.metadata?.positive_conditioning, - negativePrompt: selectedImage?.metadata?.negative_conditioning, + image: imageDTO, + seed: imageDTO?.metadata?.seed, + prompt: imageDTO?.metadata?.positive_conditioning, + negativePrompt: imageDTO?.metadata?.negative_conditioning, shouldShowProgressInViewer, }; }, diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx index c591206a27..649cae7682 100644 --- a/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx @@ -15,6 +15,8 @@ import { imageSelected } from '../store/gallerySlice'; import IAIDndImage from 'common/components/IAIDndImage'; import { ImageDTO } from 'services/api'; import { IAIImageFallback } from 'common/components/IAIImageFallback'; +import { RootState } from 'app/store/store'; +import { selectImagesById } from '../store/imagesSlice'; export const imagesSelector = createSelector( [uiSelector, gallerySelector, systemSelector], @@ -29,7 +31,7 @@ export const imagesSelector = createSelector( return { shouldShowImageDetails, shouldHidePreview, - image: selectedImage, + selectedImage, progressImage, shouldShowProgressInViewer, shouldAntialiasProgressImage, @@ -45,11 +47,16 @@ export const imagesSelector = createSelector( const CurrentImagePreview = () => { const { shouldShowImageDetails, - image, + selectedImage, progressImage, shouldShowProgressInViewer, shouldAntialiasProgressImage, } = useAppSelector(imagesSelector); + + const image = useAppSelector((state: RootState) => + selectImagesById(state, selectedImage ?? '') + ); + const dispatch = useAppDispatch(); const handleDrop = useCallback( @@ -57,7 +64,7 @@ const CurrentImagePreview = () => { if (droppedImage.image_name === image?.image_name) { return; } - dispatch(imageSelected(droppedImage)); + dispatch(imageSelected(droppedImage.image_name)); }, [dispatch, image?.image_name] ); diff --git a/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx b/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx index b21c62785b..86ec3436f0 100644 --- a/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx @@ -72,17 +72,10 @@ interface HoverableImageProps { isSelected: boolean; } -const memoEqualityCheck = ( - prev: HoverableImageProps, - next: HoverableImageProps -) => - prev.image.image_name === next.image.image_name && - prev.isSelected === next.isSelected; - /** * Gallery image component with delete/use all/use seed buttons on hover. */ -const HoverableImage = memo((props: HoverableImageProps) => { +const HoverableImage = (props: HoverableImageProps) => { const dispatch = useAppDispatch(); const { activeTabName, @@ -121,7 +114,7 @@ const HoverableImage = memo((props: HoverableImageProps) => { const handleMouseOut = () => setIsHovered(false); const handleSelectImage = useCallback(() => { - dispatch(imageSelected(image)); + dispatch(imageSelected(image.image_name)); }, [image, dispatch]); // Recall parameters handlers @@ -260,7 +253,7 @@ const HoverableImage = memo((props: HoverableImageProps) => { </MenuItem> )} <MenuItem icon={<FaFolder />} onClickCapture={handleAddToBoard}> - Add to Board + {image.board_id ? 'Change Board' : 'Add to Board'} </MenuItem> <MenuItem sx={{ color: 'error.300' }} @@ -357,8 +350,6 @@ const HoverableImage = memo((props: HoverableImageProps) => { </ContextMenu> </Box> ); -}, memoEqualityCheck); +}; -HoverableImage.displayName = 'HoverableImage'; - -export default HoverableImage; +export default memo(HoverableImage); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx index adb7791afb..48bd2bde74 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx @@ -201,12 +201,6 @@ const ImageGalleryContent = () => { dispatch(setGalleryView('boards')); }, [dispatch]); - const [newBoardName, setNewBoardName] = useState(''); - - const handleCreateNewBoard = () => { - dispatch(boardCreated({ requestBody: newBoardName })); - }; - return ( <Flex sx={{ @@ -323,9 +317,7 @@ const ImageGalleryContent = () => { <HoverableImage key={`${item.image_name}-${item.thumbnail_url}`} image={item} - isSelected={ - selectedImage?.image_name === item?.image_name - } + isSelected={selectedImage === item?.image_name} /> </Flex> )} @@ -344,9 +336,7 @@ const ImageGalleryContent = () => { <HoverableImage key={`${item.image_name}-${item.thumbnail_url}`} image={item} - isSelected={ - selectedImage?.image_name === item?.image_name - } + isSelected={selectedImage === item?.image_name} /> )} /> diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageMetaDataViewer/ImageMetadataViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageMetaDataViewer/ImageMetadataViewer.tsx index 892516a3cc..e5cb4cf4a8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageMetaDataViewer/ImageMetadataViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageMetaDataViewer/ImageMetadataViewer.tsx @@ -93,19 +93,11 @@ type ImageMetadataViewerProps = { image: ImageDTO; }; -// TODO: I don't know if this is needed. -const memoEqualityCheck = ( - prev: ImageMetadataViewerProps, - next: ImageMetadataViewerProps -) => prev.image.image_name === next.image.image_name; - -// TODO: Show more interesting information in this component. - /** * Image metadata viewer overlays currently selected image and provides * access to any of its metadata for use in processing. */ -const ImageMetadataViewer = memo(({ image }: ImageMetadataViewerProps) => { +const ImageMetadataViewer = ({ image }: ImageMetadataViewerProps) => { const dispatch = useAppDispatch(); const { recallBothPrompts, @@ -333,8 +325,6 @@ const ImageMetadataViewer = memo(({ image }: ImageMetadataViewerProps) => { </Flex> </Flex> ); -}, memoEqualityCheck); +}; -ImageMetadataViewer.displayName = 'ImageMetadataViewer'; - -export default ImageMetadataViewer; +export default memo(ImageMetadataViewer); diff --git a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx index 82e7a0d623..b1f06ad433 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx @@ -42,7 +42,7 @@ export const nextPrevImageButtonsSelector = createSelector( } const currentImageIndex = filteredImageIds.findIndex( - (i) => i === selectedImage.image_name + (i) => i === selectedImage ); const nextImageIndex = clamp( @@ -71,6 +71,8 @@ export const nextPrevImageButtonsSelector = createSelector( !isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1, nextImage, prevImage, + nextImageId, + prevImageId, }; }, { @@ -84,7 +86,7 @@ const NextPrevImageButtons = () => { const dispatch = useAppDispatch(); const { t } = useTranslation(); - const { isOnFirstImage, isOnLastImage, nextImage, prevImage } = + const { isOnFirstImage, isOnLastImage, nextImageId, prevImageId } = useAppSelector(nextPrevImageButtonsSelector); const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = @@ -99,19 +101,19 @@ const NextPrevImageButtons = () => { }, []); const handlePrevImage = useCallback(() => { - dispatch(imageSelected(prevImage)); - }, [dispatch, prevImage]); + dispatch(imageSelected(prevImageId)); + }, [dispatch, prevImageId]); const handleNextImage = useCallback(() => { - dispatch(imageSelected(nextImage)); - }, [dispatch, nextImage]); + dispatch(imageSelected(nextImageId)); + }, [dispatch, nextImageId]); useHotkeys( 'left', () => { handlePrevImage(); }, - [prevImage] + [prevImageId] ); useHotkeys( @@ -119,7 +121,7 @@ const NextPrevImageButtons = () => { () => { handleNextImage(); }, - [nextImage] + [nextImageId] ); return ( diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index a8237a711d..b07ab487ae 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -7,7 +7,7 @@ import { imageUrlsReceived } from 'services/thunks/image'; type GalleryImageObjectFitType = 'contain' | 'cover'; export interface GalleryState { - selectedImage?: ImageDTO; + selectedImage?: string; galleryImageMinimumWidth: number; galleryImageObjectFit: GalleryImageObjectFitType; shouldAutoSwitchToNewImages: boolean; @@ -27,7 +27,7 @@ export const gallerySlice = createSlice({ name: 'gallery', initialState: initialGalleryState, reducers: { - imageSelected: (state, action: PayloadAction<ImageDTO | undefined>) => { + imageSelected: (state, action: PayloadAction<string | undefined>) => { state.selectedImage = action.payload; // TODO: if the user selects an image, disable the auto switch? // state.shouldAutoSwitchToNewImages = false; @@ -63,17 +63,17 @@ export const gallerySlice = createSlice({ state.shouldAutoSwitchToNewImages && action.payload.image_category === 'general' ) { - state.selectedImage = action.payload; + state.selectedImage = action.payload.image_name; } }); - builder.addCase(imageUrlsReceived.fulfilled, (state, action) => { - const { image_name, image_url, thumbnail_url } = action.payload; + // builder.addCase(imageUrlsReceived.fulfilled, (state, action) => { + // const { image_name, image_url, thumbnail_url } = action.payload; - if (state.selectedImage?.image_name === image_name) { - state.selectedImage.image_url = image_url; - state.selectedImage.thumbnail_url = thumbnail_url; - } - }); + // if (state.selectedImage?.image_name === image_name) { + // state.selectedImage.image_url = image_url; + // state.selectedImage.thumbnail_url = thumbnail_url; + // } + // }); }, }); diff --git a/invokeai/frontend/web/src/services/api/models/BoardDTO.ts b/invokeai/frontend/web/src/services/api/models/BoardDTO.ts index 1b72f452ac..ee3c29a797 100644 --- a/invokeai/frontend/web/src/services/api/models/BoardDTO.ts +++ b/invokeai/frontend/web/src/services/api/models/BoardDTO.ts @@ -23,13 +23,9 @@ export type BoardDTO = { */ updated_at: string; /** - * The name of the cover image of the board. + * The name of the board's cover image. */ cover_image_name?: string; - /** - * The URL of the thumbnail of the board's cover image. - */ - cover_image_url?: string; /** * The number of images in the board. */ diff --git a/invokeai/frontend/web/src/services/api/services/BoardsService.ts b/invokeai/frontend/web/src/services/api/services/BoardsService.ts index 9108e3fd51..bda2b70e75 100644 --- a/invokeai/frontend/web/src/services/api/services/BoardsService.ts +++ b/invokeai/frontend/web/src/services/api/services/BoardsService.ts @@ -17,13 +17,18 @@ export class BoardsService { /** * List Boards * Gets a list of boards - * @returns OffsetPaginatedResults_BoardDTO_ Successful Response + * @returns any Successful Response * @throws ApiError */ public static listBoards({ + all, offset, - limit = 10, + limit, }: { + /** + * Whether to list all boards + */ + all?: boolean, /** * The page offset */ @@ -32,11 +37,12 @@ export class BoardsService { * The number of boards per page */ limit?: number, - }): CancelablePromise<OffsetPaginatedResults_BoardDTO_> { + }): CancelablePromise<(OffsetPaginatedResults_BoardDTO_ | Array<BoardDTO>)> { return __request(OpenAPI, { method: 'GET', url: '/api/v1/boards/', query: { + 'all': all, 'offset': offset, 'limit': limit, }, @@ -53,15 +59,19 @@ export class BoardsService { * @throws ApiError */ public static createBoard({ - requestBody, + boardName, }: { - requestBody: string, + /** + * The name of the board to create + */ + boardName: string, }): CancelablePromise<BoardDTO> { return __request(OpenAPI, { method: 'POST', url: '/api/v1/boards/', - body: requestBody, - mediaType: 'application/json', + query: { + 'board_name': boardName, + }, errors: { 422: `Validation Error`, }, diff --git a/invokeai/frontend/web/src/services/apiSlice.ts b/invokeai/frontend/web/src/services/apiSlice.ts new file mode 100644 index 0000000000..09eb061e29 --- /dev/null +++ b/invokeai/frontend/web/src/services/apiSlice.ts @@ -0,0 +1,144 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { BoardDTO } from './api/models/BoardDTO'; +import { OffsetPaginatedResults_BoardDTO_ } from './api/models/OffsetPaginatedResults_BoardDTO_'; +import { BoardChanges } from './api/models/BoardChanges'; +import { OffsetPaginatedResults_ImageDTO_ } from './api/models/OffsetPaginatedResults_ImageDTO_'; + +type ListBoardsArg = { offset: number; limit: number }; +type UpdateBoardArg = { board_id: string; changes: BoardChanges }; +type AddImageToBoardArg = { board_id: string; image_name: string }; +type RemoveImageFromBoardArg = { board_id: string; image_name: string }; +type ListBoardImagesArg = { board_id: string; offset: number; limit: number }; + +export const api = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:5173/api/v1/' }), + reducerPath: 'api', + tagTypes: ['Board'], + endpoints: (build) => ({ + /** + * Boards Queries + */ + listBoards: build.query<OffsetPaginatedResults_BoardDTO_, ListBoardsArg>({ + query: (arg) => ({ url: 'boards/', params: arg }), + providesTags: (result, error, arg) => { + if (!result) { + // Provide the broad 'Board' tag until there is a response + return ['Board']; + } + + // Provide the broad 'Board' tab, and individual tags for each board + return [ + ...result.items.map(({ board_id }) => ({ + type: 'Board' as const, + id: board_id, + })), + 'Board', + ]; + }, + }), + + listAllBoards: build.query<Array<BoardDTO>, void>({ + query: () => ({ + url: 'boards/', + params: { all: true }, + }), + providesTags: (result, error, arg) => { + if (!result) { + // Provide the broad 'Board' tag until there is a response + return ['Board']; + } + + // Provide the broad 'Board' tab, and individual tags for each board + return [ + ...result.map(({ board_id }) => ({ + type: 'Board' as const, + id: board_id, + })), + 'Board', + ]; + }, + }), + + /** + * Boards Mutations + */ + + createBoard: build.mutation<BoardDTO, string>({ + query: (board_name) => ({ + url: `boards/`, + method: 'POST', + params: { board_name }, + }), + invalidatesTags: ['Board'], + }), + + updateBoard: build.mutation<BoardDTO, UpdateBoardArg>({ + query: ({ board_id, changes }) => ({ + url: `boards/${board_id}`, + method: 'PATCH', + body: changes, + }), + invalidatesTags: (result, error, arg) => [ + { type: 'Board', id: arg.board_id }, + ], + }), + + deleteBoard: build.mutation<void, string>({ + query: (board_id) => ({ url: `boards/${board_id}`, method: 'DELETE' }), + invalidatesTags: (result, error, arg) => [{ type: 'Board', id: arg }], + }), + + /** + * Board Images Queries + */ + + listBoardImages: build.query< + OffsetPaginatedResults_ImageDTO_, + ListBoardImagesArg + >({ + query: ({ board_id, offset, limit }) => ({ + url: `board_images/${board_id}`, + method: 'DELETE', + body: { offset, limit }, + }), + }), + + /** + * Board Images Mutations + */ + + addImageToBoard: build.mutation<void, AddImageToBoardArg>({ + query: ({ board_id, image_name }) => ({ + url: `board_images/`, + method: 'POST', + body: { board_id, image_name }, + }), + invalidatesTags: ['Board'], + // invalidatesTags: (result, error, arg) => [ + // { type: 'Board', id: arg.board_id }, + // ], + }), + + removeImageFromBoard: build.mutation<void, RemoveImageFromBoardArg>({ + query: ({ board_id, image_name }) => ({ + url: `board_images/`, + method: 'DELETE', + body: { board_id, image_name }, + }), + invalidatesTags: (result, error, arg) => [ + { type: 'Board', id: arg.board_id }, + ], + }), + }), +}); + +export const { + useListBoardsQuery, + useListAllBoardsQuery, + useCreateBoardMutation, + useUpdateBoardMutation, + useDeleteBoardMutation, + useAddImageToBoardMutation, + useRemoveImageFromBoardMutation, + useListBoardImagesQuery, +} = api; diff --git a/invokeai/frontend/web/src/services/thunks/board.ts b/invokeai/frontend/web/src/services/thunks/board.ts index 4535081e47..03c59dba10 100644 --- a/invokeai/frontend/web/src/services/thunks/board.ts +++ b/invokeai/frontend/web/src/services/thunks/board.ts @@ -42,7 +42,7 @@ export const boardUpdated = createAppAsyncThunk( type ImageAddedToBoardArg = Parameters< (typeof BoardsService)['createBoardImage'] ->[0]; +>[0]['requestBody']; export const imageAddedToBoard = createAppAsyncThunk( 'api/imageAddedToBoard',