ui: boards 2: electric boogaloo (#3869)

## What type of PR is this? (check all applicable)

- [x] Refactor
- [ ] Feature
- [ ] Bug Fix
- [ ] Optimization
- [ ] Documentation Update
- [ ] Community Node Submission


## Have you discussed this change with the InvokeAI team?
- [x] Yes
- [ ] No, because:


## Description

Revised boards logic and UI

## Related Tickets & Documents

<!--
For pull requests that relate or close an issue, please include them
below. 

For example having the text: "closes #1234" would connect the current
pull
request to issue 1234.  And when we merge the pull request, Github will
automatically close the issue.
-->

- Related Issue # discord convos
- Closes #

## QA Instructions, Screenshots, Recordings

<!-- 
Please provide steps on how to test changes, any hardware or 
software specifications as well as any other pertinent information. 
-->

## Added/updated tests?

- [ ] Yes
- [x] No : n/a

## [optional] Are there any post deployment tasks we need to perform?
This commit is contained in:
Lincoln Stein 2023-07-21 06:42:16 -04:00 committed by GitHub
commit fba4085939
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 957 additions and 751 deletions

View File

@ -175,9 +175,7 @@ export const isValidDrop = (
const destinationBoard = overData.context.boardId; const destinationBoard = overData.context.boardId;
const isSameBoard = currentBoard === destinationBoard; const isSameBoard = currentBoard === destinationBoard;
const isDestinationValid = !currentBoard const isDestinationValid = !currentBoard ? destinationBoard : true;
? destinationBoard !== 'no_board'
: true;
return !isSameBoard && isDestinationValid; return !isSameBoard && isDestinationValid;
} }

View File

@ -1,20 +1,20 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
boardIdSelected, boardIdSelected,
galleryViewChanged,
imageSelected, imageSelected,
} from 'features/gallery/store/gallerySlice'; } from 'features/gallery/store/gallerySlice';
import {
getBoardIdQueryParamForBoard,
getCategoriesQueryParamForBoard,
} from 'features/gallery/store/util';
import { imagesApi } from 'services/api/endpoints/images'; import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..'; import { startAppListening } from '..';
import { isAnyOf } from '@reduxjs/toolkit';
const moduleLog = log.child({ namespace: 'boards' }); const moduleLog = log.child({ namespace: 'boards' });
export const addBoardIdSelectedListener = () => { export const addBoardIdSelectedListener = () => {
startAppListening({ startAppListening({
actionCreator: boardIdSelected, matcher: isAnyOf(boardIdSelected, galleryViewChanged),
effect: async ( effect: async (
action, action,
{ getState, dispatch, condition, cancelActiveListeners } { getState, dispatch, condition, cancelActiveListeners }
@ -22,12 +22,21 @@ export const addBoardIdSelectedListener = () => {
// Cancel any in-progress instances of this listener, we don't want to select an image from a previous board // Cancel any in-progress instances of this listener, we don't want to select an image from a previous board
cancelActiveListeners(); cancelActiveListeners();
const _board_id = action.payload; const state = getState();
// when a board is selected, we need to wait until the board has loaded *some* images, then select the first one
const categories = getCategoriesQueryParamForBoard(_board_id); const board_id = boardIdSelected.match(action)
const board_id = getBoardIdQueryParamForBoard(_board_id); ? action.payload
const queryArgs = { board_id, categories }; : state.gallery.selectedBoardId;
const galleryView = galleryViewChanged.match(action)
? action.payload
: state.gallery.galleryView;
// when a board is selected, we need to wait until the board has loaded *some* images, then select the first one
const categories =
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES;
const queryArgs = { board_id: board_id ?? 'none', categories };
// wait until the board has some images - maybe it already has some from a previous fetch // wait until the board has some images - maybe it already has some from a previous fetch
// must use getState() to ensure we do not have stale state // must use getState() to ensure we do not have stale state

View File

@ -156,14 +156,13 @@ export const addImageDroppedListener = () => {
if ( if (
overData.actionType === 'MOVE_BOARD' && overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_DTO' && activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO && activeData.payload.imageDTO
overData.context.boardId
) { ) {
const { imageDTO } = activeData.payload; const { imageDTO } = activeData.payload;
const { boardId } = overData.context; const { boardId } = overData.context;
// if the board is "No Board", this is a remove action // image was droppe on the "NoBoardBoard"
if (boardId === 'no_board') { if (!boardId) {
dispatch( dispatch(
imagesApi.endpoints.removeImageFromBoard.initiate({ imagesApi.endpoints.removeImageFromBoard.initiate({
imageDTO, imageDTO,
@ -172,12 +171,7 @@ export const addImageDroppedListener = () => {
return; return;
} }
// Handle adding image to batch // image was dropped on a user board
if (boardId === 'batch') {
// TODO
}
// Otherwise, add the image to the board
dispatch( dispatch(
imagesApi.endpoints.addImageToBoard.initiate({ imagesApi.endpoints.addImageToBoard.initiate({
imageDTO, imageDTO,

View File

@ -5,30 +5,30 @@ import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'image' }); const moduleLog = log.child({ namespace: 'image' });
export const addImageUpdatedFulfilledListener = () => { export const addImageUpdatedFulfilledListener = () => {
startAppListening({ // startAppListening({
matcher: imagesApi.endpoints.updateImage.matchFulfilled, // matcher: imagesApi.endpoints.updateImage.matchFulfilled,
effect: (action, { dispatch, getState }) => { // effect: (action, { dispatch, getState }) => {
moduleLog.debug( // moduleLog.debug(
{ // {
data: { // data: {
oldImage: action.meta.arg.originalArgs, // oldImage: action.meta.arg.originalArgs,
updatedImage: action.payload, // updatedImage: action.payload,
}, // },
}, // },
'Image updated' // 'Image updated'
); // );
}, // },
}); // });
}; };
export const addImageUpdatedRejectedListener = () => { export const addImageUpdatedRejectedListener = () => {
startAppListening({ // startAppListening({
matcher: imagesApi.endpoints.updateImage.matchRejected, // matcher: imagesApi.endpoints.updateImage.matchRejected,
effect: (action, { dispatch }) => { // effect: (action, { dispatch }) => {
moduleLog.debug( // moduleLog.debug(
{ data: action.meta.arg.originalArgs }, // { data: action.meta.arg.originalArgs },
'Image update failed' // 'Image update failed'
); // );
}, // },
}); // });
}; };

View File

@ -3,6 +3,7 @@ import { addImageToStagingArea } from 'features/canvas/store/canvasSlice';
import { import {
IMAGE_CATEGORIES, IMAGE_CATEGORIES,
boardIdSelected, boardIdSelected,
galleryViewChanged,
imageSelected, imageSelected,
} from 'features/gallery/store/gallerySlice'; } from 'features/gallery/store/gallerySlice';
import { progressImageSet } from 'features/system/store/systemSlice'; import { progressImageSet } from 'features/system/store/systemSlice';
@ -55,34 +56,6 @@ export const addInvocationCompleteEventListener = () => {
} }
if (!imageDTO.is_intermediate) { if (!imageDTO.is_intermediate) {
// update the cache for 'All Images'
dispatch(
imagesApi.util.updateQueryData(
'listImages',
{
categories: IMAGE_CATEGORIES,
},
(draft) => {
imagesAdapter.addOne(draft, imageDTO);
draft.total = draft.total + 1;
}
)
);
// update the cache for 'No Board'
dispatch(
imagesApi.util.updateQueryData(
'listImages',
{
board_id: 'none',
},
(draft) => {
imagesAdapter.addOne(draft, imageDTO);
draft.total = draft.total + 1;
}
)
);
const { autoAddBoardId } = gallery; const { autoAddBoardId } = gallery;
// add image to the board if auto-add is enabled // add image to the board if auto-add is enabled
@ -93,8 +66,31 @@ export const addInvocationCompleteEventListener = () => {
imageDTO, imageDTO,
}) })
); );
} else {
// add to no board board
// update the cache for 'No Board'
dispatch(
imagesApi.util.updateQueryData(
'listImages',
{
board_id: 'none',
categories: IMAGE_CATEGORIES,
},
(draft) => {
imagesAdapter.addOne(draft, imageDTO);
draft.total = draft.total + 1;
}
)
);
} }
dispatch(
imagesApi.util.invalidateTags([
{ type: 'BoardImagesTotal', id: autoAddBoardId ?? 'none' },
{ type: 'BoardAssetsTotal', id: autoAddBoardId ?? 'none' },
])
);
const { selectedBoardId, shouldAutoSwitch } = gallery; const { selectedBoardId, shouldAutoSwitch } = gallery;
// If auto-switch is enabled, select the new image // If auto-switch is enabled, select the new image
@ -102,8 +98,9 @@ export const addInvocationCompleteEventListener = () => {
// if auto-add is enabled, switch the board as the image comes in // if auto-add is enabled, switch the board as the image comes in
if (autoAddBoardId && autoAddBoardId !== selectedBoardId) { if (autoAddBoardId && autoAddBoardId !== selectedBoardId) {
dispatch(boardIdSelected(autoAddBoardId)); dispatch(boardIdSelected(autoAddBoardId));
dispatch(galleryViewChanged('images'));
} else if (!autoAddBoardId) { } else if (!autoAddBoardId) {
dispatch(boardIdSelected('images')); dispatch(galleryViewChanged('images'));
} }
dispatch(imageSelected(imageDTO.image_name)); dispatch(imageSelected(imageDTO.image_name));
} }

View File

@ -0,0 +1,23 @@
import { Badge, Flex } from '@chakra-ui/react';
const AutoAddIcon = () => {
return (
<Flex
sx={{
position: 'absolute',
insetInlineStart: 0,
top: 0,
p: 1,
}}
>
<Badge
variant="solid"
sx={{ fontSize: 10, bg: 'accent.400', _dark: { bg: 'accent.500' } }}
>
auto
</Badge>
</Flex>
);
};
export default AutoAddIcon;

View File

@ -52,7 +52,7 @@ const BoardAutoAddSelect = () => {
return; return;
} }
dispatch(autoAddBoardIdChanged(v === 'none' ? null : v)); dispatch(autoAddBoardIdChanged(v === 'none' ? undefined : v));
}, },
[dispatch] [dispatch]
); );

View File

@ -1,4 +1,4 @@
import { Box, MenuItem, MenuList } from '@chakra-ui/react'; import { MenuItem, MenuList } from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks'; import { useAppDispatch } from 'app/store/storeHooks';
import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu'; import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu';
import { boardIdSelected } from 'features/gallery/store/gallerySlice'; import { boardIdSelected } from 'features/gallery/store/gallerySlice';
@ -7,11 +7,11 @@ import { FaFolder } from 'react-icons/fa';
import { BoardDTO } from 'services/api/types'; import { BoardDTO } from 'services/api/types';
import { menuListMotionProps } from 'theme/components/menu'; import { menuListMotionProps } from 'theme/components/menu';
import GalleryBoardContextMenuItems from './GalleryBoardContextMenuItems'; import GalleryBoardContextMenuItems from './GalleryBoardContextMenuItems';
import SystemBoardContextMenuItems from './SystemBoardContextMenuItems'; import NoBoardContextMenuItems from './NoBoardContextMenuItems';
type Props = { type Props = {
board?: BoardDTO; board?: BoardDTO;
board_id: string; board_id?: string;
children: ContextMenuProps<HTMLDivElement>['children']; children: ContextMenuProps<HTMLDivElement>['children'];
setBoardToDelete?: (board?: BoardDTO) => void; setBoardToDelete?: (board?: BoardDTO) => void;
}; };
@ -19,9 +19,11 @@ type Props = {
const BoardContextMenu = memo( const BoardContextMenu = memo(
({ board, board_id, setBoardToDelete, children }: Props) => { ({ board, board_id, setBoardToDelete, children }: Props) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleSelectBoard = useCallback(() => { const handleSelectBoard = useCallback(() => {
dispatch(boardIdSelected(board?.board_id ?? board_id)); dispatch(boardIdSelected(board?.board_id ?? board_id));
}, [board?.board_id, board_id, dispatch]); }, [board?.board_id, board_id, dispatch]);
return ( return (
<ContextMenu<HTMLDivElement> <ContextMenu<HTMLDivElement>
menuProps={{ size: 'sm', isLazy: true }} menuProps={{ size: 'sm', isLazy: true }}
@ -37,7 +39,7 @@ const BoardContextMenu = memo(
<MenuItem icon={<FaFolder />} onClickCapture={handleSelectBoard}> <MenuItem icon={<FaFolder />} onClickCapture={handleSelectBoard}>
Select Board Select Board
</MenuItem> </MenuItem>
{!board && <SystemBoardContextMenuItems board_id={board_id} />} {!board && <NoBoardContextMenuItems />}
{board && ( {board && (
<GalleryBoardContextMenuItems <GalleryBoardContextMenuItems
board={board} board={board}

View File

@ -16,6 +16,7 @@ import AddBoardButton from './AddBoardButton';
import BoardsSearch from './BoardsSearch'; import BoardsSearch from './BoardsSearch';
import GalleryBoard from './GalleryBoard'; import GalleryBoard from './GalleryBoard';
import SystemBoardButton from './SystemBoardButton'; import SystemBoardButton from './SystemBoardButton';
import NoBoardBoard from './NoBoardBoard';
const selector = createSelector( const selector = createSelector(
[stateSelector], [stateSelector],
@ -42,10 +43,6 @@ const BoardsList = (props: Props) => {
) )
: boards; : boards;
const [boardToDelete, setBoardToDelete] = useState<BoardDTO>(); const [boardToDelete, setBoardToDelete] = useState<BoardDTO>();
const [isSearching, setIsSearching] = useState(false);
const handleClickSearchIcon = useCallback(() => {
setIsSearching((v) => !v);
}, []);
return ( return (
<> <>
@ -61,54 +58,7 @@ const BoardsList = (props: Props) => {
}} }}
> >
<Flex sx={{ gap: 2, alignItems: 'center' }}> <Flex sx={{ gap: 2, alignItems: 'center' }}>
<AnimatePresence mode="popLayout"> <BoardsSearch />
{isSearching ? (
<motion.div
key="boards-search"
initial={{
opacity: 0,
}}
exit={{
opacity: 0,
}}
animate={{
opacity: 1,
transition: { duration: 0.1 },
}}
style={{ width: '100%' }}
>
<BoardsSearch setIsSearching={setIsSearching} />
</motion.div>
) : (
<motion.div
key="system-boards-select"
initial={{
opacity: 0,
}}
exit={{
opacity: 0,
}}
animate={{
opacity: 1,
transition: { duration: 0.1 },
}}
style={{ width: '100%' }}
>
<ButtonGroup sx={{ w: 'full', ps: 1.5 }} isAttached>
<SystemBoardButton board_id="images" />
<SystemBoardButton board_id="assets" />
<SystemBoardButton board_id="no_board" />
</ButtonGroup>
</motion.div>
)}
</AnimatePresence>
<IAIIconButton
aria-label="Search Boards"
size="sm"
isChecked={isSearching}
onClick={handleClickSearchIcon}
icon={<FaSearch />}
/>
<AddBoardButton /> <AddBoardButton />
</Flex> </Flex>
<OverlayScrollbarsComponent <OverlayScrollbarsComponent
@ -130,6 +80,9 @@ const BoardsList = (props: Props) => {
maxH: 346, maxH: 346,
}} }}
> >
<GridItem sx={{ p: 1.5 }}>
<NoBoardBoard isSelected={selectedBoardId === undefined} />
</GridItem>
{filteredBoards && {filteredBoards &&
filteredBoards.map((board) => ( filteredBoards.map((board) => (
<GridItem key={board.board_id} sx={{ p: 1.5 }}> <GridItem key={board.board_id} sx={{ p: 1.5 }}>

View File

@ -28,12 +28,7 @@ const selector = createSelector(
defaultSelectorOptions defaultSelectorOptions
); );
type Props = { const BoardsSearch = () => {
setIsSearching: (isSearching: boolean) => void;
};
const BoardsSearch = (props: Props) => {
const { setIsSearching } = props;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { searchText } = useAppSelector(selector); const { searchText } = useAppSelector(selector);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@ -47,8 +42,7 @@ const BoardsSearch = (props: Props) => {
const clearBoardSearch = useCallback(() => { const clearBoardSearch = useCallback(() => {
dispatch(setBoardSearchText('')); dispatch(setBoardSearchText(''));
setIsSearching(false); }, [dispatch]);
}, [dispatch, setIsSearching]);
const handleKeydown = useCallback( const handleKeydown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => { (e: KeyboardEvent<HTMLInputElement>) => {

View File

@ -19,17 +19,14 @@ import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIDroppable from 'common/components/IAIDroppable'; import IAIDroppable from 'common/components/IAIDroppable';
import { boardIdSelected } from 'features/gallery/store/gallerySlice'; import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useMemo, useState } from 'react'; import { memo, useCallback, useMemo, useState } from 'react';
import { FaFolder } from 'react-icons/fa'; import { FaUser } from 'react-icons/fa';
import { useUpdateBoardMutation } from 'services/api/endpoints/boards'; import { useUpdateBoardMutation } from 'services/api/endpoints/boards';
import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { useBoardTotal } from 'services/api/hooks/useBoardTotal';
import { BoardDTO } from 'services/api/types'; import { BoardDTO } from 'services/api/types';
import AutoAddIcon from '../AutoAddIcon';
import BoardContextMenu from '../BoardContextMenu'; import BoardContextMenu from '../BoardContextMenu';
const AUTO_ADD_BADGE_STYLES: ChakraProps['sx'] = {
bg: 'accent.200',
color: 'blackAlpha.900',
};
const BASE_BADGE_STYLES: ChakraProps['sx'] = { const BASE_BADGE_STYLES: ChakraProps['sx'] = {
bg: 'base.500', bg: 'base.500',
color: 'whiteAlpha.900', color: 'whiteAlpha.900',
@ -64,6 +61,8 @@ const GalleryBoard = memo(
board.cover_image_name ?? skipToken board.cover_image_name ?? skipToken
); );
const { totalImages, totalAssets } = useBoardTotal(board.board_id);
const { board_name, board_id } = board; const { board_name, board_id } = board;
const [localBoardName, setLocalBoardName] = useState(board_name); const [localBoardName, setLocalBoardName] = useState(board_name);
@ -143,56 +142,48 @@ const GalleryBoard = memo(
alignItems: 'center', alignItems: 'center',
borderRadius: 'base', borderRadius: 'base',
cursor: 'pointer', cursor: 'pointer',
bg: 'base.200',
_dark: {
bg: 'base.800',
},
}} }}
> >
<Flex {coverImage?.thumbnail_url ? (
sx={{ <Image
w: 'full', src={coverImage?.thumbnail_url}
h: 'full', draggable={false}
justifyContent: 'center', sx={{
alignItems: 'center', objectFit: 'cover',
borderRadius: 'base', w: 'full',
bg: 'base.200', h: 'full',
_dark: { maxH: 'full',
bg: 'base.800', borderRadius: 'base',
}, borderBottomRadius: 'lg',
}} }}
> />
{coverImage?.thumbnail_url ? ( ) : (
<Image <Flex
src={coverImage?.thumbnail_url} sx={{
draggable={false} w: 'full',
h: 'full',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Icon
boxSize={12}
as={FaUser}
sx={{ sx={{
maxW: 'full', mt: -3,
maxH: 'full', opacity: 0.7,
borderRadius: 'base', color: 'base.500',
borderBottomRadius: 'lg', _dark: {
color: 'base.500',
},
}} }}
/> />
) : ( </Flex>
<Flex )}
sx={{
w: 'full',
h: 'full',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Icon
boxSize={12}
as={FaFolder}
sx={{
mt: -3,
opacity: 0.7,
color: 'base.500',
_dark: {
color: 'base.500',
},
}}
/>
</Flex>
)}
</Flex>
<Flex <Flex
sx={{ sx={{
position: 'absolute', position: 'absolute',
@ -201,17 +192,11 @@ const GalleryBoard = memo(
p: 1, p: 1,
}} }}
> >
<Badge <Badge variant="solid" sx={BASE_BADGE_STYLES}>
variant="solid" {totalImages}/{totalAssets}
sx={
isSelectedForAutoAdd
? AUTO_ADD_BADGE_STYLES
: BASE_BADGE_STYLES
}
>
{board.image_count}
</Badge> </Badge>
</Flex> </Flex>
{isSelectedForAutoAdd && <AutoAddIcon />}
<Box <Box
className="selection-box" className="selection-box"
sx={{ sx={{

View File

@ -1,54 +1,145 @@
import { Text } from '@chakra-ui/react'; import { Badge, Box, ChakraProps, Flex, Icon, Text } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd'; import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
import { import { stateSelector } from 'app/store/store';
INITIAL_IMAGE_LIMIT, import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
boardIdSelected, import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
} from 'features/gallery/store/gallerySlice'; import IAIDroppable from 'common/components/IAIDroppable';
import { FaFolderOpen } from 'react-icons/fa'; import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { useDispatch } from 'react-redux'; import { memo, useCallback, useMemo } from 'react';
import { import { FaFolder } from 'react-icons/fa';
ListImagesArgs, import { useBoardTotal } from 'services/api/hooks/useBoardTotal';
useListImagesQuery, import AutoAddIcon from '../AutoAddIcon';
} from 'services/api/endpoints/images'; import BoardContextMenu from '../BoardContextMenu';
import GenericBoard from './GenericBoard';
const baseQueryArg: ListImagesArgs = { const BASE_BADGE_STYLES: ChakraProps['sx'] = {
board_id: 'none', bg: 'base.500',
offset: 0, color: 'whiteAlpha.900',
limit: INITIAL_IMAGE_LIMIT,
is_intermediate: false,
}; };
interface Props {
isSelected: boolean;
}
const NoBoardBoard = ({ isSelected }: { isSelected: boolean }) => { const selector = createSelector(
const dispatch = useDispatch(); stateSelector,
({ gallery }) => {
const { autoAddBoardId } = gallery;
return { autoAddBoardId };
},
defaultSelectorOptions
);
const handleClick = () => { const NoBoardBoard = memo(({ isSelected }: Props) => {
dispatch(boardIdSelected('no_board')); const dispatch = useAppDispatch();
}; const { totalImages, totalAssets } = useBoardTotal(undefined);
const { autoAddBoardId } = useAppSelector(selector);
const handleSelectBoard = useCallback(() => {
dispatch(boardIdSelected(undefined));
}, [dispatch]);
const { total } = useListImagesQuery(baseQueryArg, { const droppableData: MoveBoardDropData = useMemo(
selectFromResult: ({ data }) => ({ total: data?.total ?? 0 }), () => ({
}); id: 'no_board',
actionType: 'MOVE_BOARD',
// TODO: Do we support making 'images' 'assets? if yes, we need to handle this context: { boardId: undefined },
const droppableData: MoveBoardDropData = { }),
id: 'all-images-board', []
actionType: 'MOVE_BOARD', );
context: { boardId: 'no_board' },
};
return ( return (
<GenericBoard <Box sx={{ w: 'full', h: 'full', touchAction: 'none', userSelect: 'none' }}>
board_id="no_board" <Flex
droppableData={droppableData} sx={{
dropLabel={<Text fontSize="md">Move</Text>} position: 'relative',
onClick={handleClick} justifyContent: 'center',
isSelected={isSelected} alignItems: 'center',
icon={FaFolderOpen} aspectRatio: '1/1',
label="No Board" borderRadius: 'base',
badgeCount={total} w: 'full',
/> h: 'full',
}}
>
<BoardContextMenu>
{(ref) => (
<Flex
ref={ref}
onClick={handleSelectBoard}
sx={{
w: 'full',
h: 'full',
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
borderRadius: 'base',
cursor: 'pointer',
bg: 'base.300',
_dark: {
bg: 'base.900',
},
}}
>
<Flex
sx={{
w: 'full',
h: 'full',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Icon
boxSize={12}
as={FaFolder}
sx={{
opacity: 0.7,
color: 'base.500',
_dark: {
color: 'base.500',
},
}}
/>
</Flex>
<Flex
sx={{
position: 'absolute',
insetInlineEnd: 0,
top: 0,
p: 1,
}}
>
<Badge variant="solid" sx={BASE_BADGE_STYLES}>
{totalImages}/{totalAssets}
</Badge>
</Flex>
{!autoAddBoardId && <AutoAddIcon />}
<Box
className="selection-box"
sx={{
position: 'absolute',
top: 0,
insetInlineEnd: 0,
bottom: 0,
insetInlineStart: 0,
borderRadius: 'base',
transitionProperty: 'common',
transitionDuration: 'common',
shadow: isSelected ? 'selected.light' : undefined,
_dark: {
shadow: isSelected ? 'selected.dark' : undefined,
},
}}
/>
<IAIDroppable
data={droppableData}
dropLabel={<Text fontSize="md">Move</Text>}
/>
</Flex>
)}
</BoardContextMenu>
</Flex>
</Box>
); );
}; });
NoBoardBoard.displayName = 'HoverableBoard';
export default NoBoardBoard; export default NoBoardBoard;

View File

@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { autoAddBoardIdChanged } from 'features/gallery/store/gallerySlice'; import { autoAddBoardIdChanged } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useMemo } from 'react'; import { memo, useCallback, useMemo } from 'react';
import { FaMinus, FaPlus, FaTrash } from 'react-icons/fa'; import { FaPlus, FaTrash } from 'react-icons/fa';
import { BoardDTO } from 'services/api/types'; import { BoardDTO } from 'services/api/types';
type Props = { type Props = {
@ -42,7 +42,7 @@ const GalleryBoardContextMenuItems = ({ board, setBoardToDelete }: Props) => {
const handleToggleAutoAdd = useCallback(() => { const handleToggleAutoAdd = useCallback(() => {
dispatch( dispatch(
autoAddBoardIdChanged(isSelectedForAutoAdd ? null : board.board_id) autoAddBoardIdChanged(isSelectedForAutoAdd ? undefined : board.board_id)
); );
}, [board.board_id, dispatch, isSelectedForAutoAdd]); }, [board.board_id, dispatch, isSelectedForAutoAdd]);
@ -59,16 +59,15 @@ const GalleryBoardContextMenuItems = ({ board, setBoardToDelete }: Props) => {
</MenuItem> */} </MenuItem> */}
</> </>
)} )}
<MenuItem {!isSelectedForAutoAdd && (
icon={isSelectedForAutoAdd ? <FaMinus /> : <FaPlus />} <MenuItem icon={<FaPlus />} onClick={handleToggleAutoAdd}>
onClickCapture={handleToggleAutoAdd} Auto-add to this Board
> </MenuItem>
{isSelectedForAutoAdd ? 'Disable Auto-Add' : 'Auto-Add to this Board'} )}
</MenuItem>
<MenuItem <MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }} sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />} icon={<FaTrash />}
onClickCapture={handleDelete} onClick={handleDelete}
> >
Delete Board Delete Board
</MenuItem> </MenuItem>

View File

@ -0,0 +1,28 @@
import { MenuItem } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { autoAddBoardIdChanged } from 'features/gallery/store/gallerySlice';
import { memo, useCallback } from 'react';
import { FaPlus } from 'react-icons/fa';
const NoBoardContextMenuItems = () => {
const dispatch = useAppDispatch();
const autoAddBoardId = useAppSelector(
(state) => state.gallery.autoAddBoardId
);
const handleDisableAutoAdd = useCallback(() => {
dispatch(autoAddBoardIdChanged(undefined));
}, [dispatch]);
return (
<>
{autoAddBoardId && (
<MenuItem icon={<FaPlus />} onClick={handleDisableAutoAdd}>
Auto-add to this Board
</MenuItem>
)}
</>
);
};
export default memo(NoBoardContextMenuItems);

View File

@ -1,12 +0,0 @@
import { BoardId } from 'features/gallery/store/gallerySlice';
import { memo } from 'react';
type Props = {
board_id: BoardId;
};
const SystemBoardContextMenuItems = ({ board_id }: Props) => {
return <></>;
};
export default memo(SystemBoardContextMenuItems);

View File

@ -1,5 +1,5 @@
import { ChevronUpIcon } from '@chakra-ui/icons'; import { ChevronUpIcon } from '@chakra-ui/icons';
import { Box, Button, Flex, Spacer, Text } from '@chakra-ui/react'; import { Button, Flex, Text } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
@ -27,52 +27,60 @@ const GalleryBoardName = (props: Props) => {
const { isOpen, onToggle } = props; const { isOpen, onToggle } = props;
const { selectedBoardId } = useAppSelector(selector); const { selectedBoardId } = useAppSelector(selector);
const boardName = useBoardName(selectedBoardId); const boardName = useBoardName(selectedBoardId);
const numOfBoardImages = useBoardTotal(selectedBoardId); const { totalImages, totalAssets } = useBoardTotal(selectedBoardId);
const formattedBoardName = useMemo(() => { const formattedBoardName = useMemo(() => {
if (!boardName) return ''; if (!boardName) {
if (boardName && !numOfBoardImages) return boardName; return '';
if (boardName.length > 20) {
return `${boardName.substring(0, 20)}... (${numOfBoardImages})`;
} }
return `${boardName} (${numOfBoardImages})`;
}, [boardName, numOfBoardImages]); if (boardName && (totalImages === undefined || totalAssets === undefined)) {
return boardName;
}
const count = `${totalImages}/${totalAssets}`;
if (boardName.length > 20) {
return `${boardName.substring(0, 20)}... (${count})`;
}
return `${boardName} (${count})`;
}, [boardName, totalAssets, totalImages]);
return ( return (
<Flex <Flex
as={Button} as={Button}
onClick={onToggle} onClick={onToggle}
size="sm" size="sm"
variant="ghost" // variant="ghost"
sx={{ sx={{
position: 'relative', position: 'relative',
gap: 2, gap: 2,
w: 'full', w: 'full',
justifyContent: 'center', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
px: 2, px: 2,
_hover: { // bg: 'base.100',
bg: 'base.100', // _dark: { bg: 'base.800' },
_dark: { bg: 'base.800' }, // _hover: {
}, // bg: 'base.200',
// _dark: { bg: 'base.700' },
// },
}} }}
> >
<Spacer /> <Text
<Box position="relative"> noOfLines={1}
<Text sx={{
noOfLines={1} fontWeight: 600,
sx={{ w: '100%',
fontWeight: 600, textAlign: 'center',
color: 'base.800', color: 'base.800',
_dark: { _dark: {
color: 'base.200', color: 'base.200',
}, },
}} }}
> >
{formattedBoardName} {formattedBoardName}
</Text> </Text>
</Box>
<Spacer />
<ChevronUpIcon <ChevronUpIcon
sx={{ sx={{
transform: isOpen ? 'rotate(0deg)' : 'rotate(180deg)', transform: isOpen ? 'rotate(0deg)' : 'rotate(180deg)',

View File

@ -35,6 +35,8 @@ import {
import { ImageDTO } from 'services/api/types'; import { ImageDTO } from 'services/api/types';
import { AddImageToBoardContext } from '../../../../app/contexts/AddImageToBoardContext'; import { AddImageToBoardContext } from '../../../../app/contexts/AddImageToBoardContext';
import { sentImageToCanvas, sentImageToImg2Img } from '../../store/actions'; import { sentImageToCanvas, sentImageToImg2Img } from '../../store/actions';
import { useDebounce } from 'use-debounce';
import { skipToken } from '@reduxjs/toolkit/dist/query';
type SingleSelectionMenuItemsProps = { type SingleSelectionMenuItemsProps = {
imageDTO: ImageDTO; imageDTO: ImageDTO;
@ -70,7 +72,16 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
const { onClickAddToBoard } = useContext(AddImageToBoardContext); const { onClickAddToBoard } = useContext(AddImageToBoardContext);
const { currentData } = useGetImageMetadataQuery(imageDTO.image_name); const [debouncedMetadataQueryArg, debounceState] = useDebounce(
imageDTO.image_name,
500
);
const { currentData } = useGetImageMetadataQuery(
debounceState.isPending()
? skipToken
: debouncedMetadataQueryArg ?? skipToken
);
const { isClipboardAPIAvailable, copyImageToClipboard } = const { isClipboardAPIAvailable, copyImageToClipboard } =
useCopyImageToClipboard(); useCopyImageToClipboard();

View File

@ -1,23 +1,37 @@
import { Box, Flex, VStack, useDisclosure } from '@chakra-ui/react'; import {
Box,
ButtonGroup,
Flex,
Spacer,
Tab,
TabList,
Tabs,
VStack,
useDisclosure,
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { memo, useRef } from 'react'; import { memo, useCallback, useRef } from 'react';
import BoardsList from './Boards/BoardsList/BoardsList'; import BoardsList from './Boards/BoardsList/BoardsList';
import GalleryBoardName from './GalleryBoardName'; import GalleryBoardName from './GalleryBoardName';
import GalleryPinButton from './GalleryPinButton'; import GalleryPinButton from './GalleryPinButton';
import GallerySettingsPopover from './GallerySettingsPopover'; import GallerySettingsPopover from './GallerySettingsPopover';
import BatchImageGrid from './ImageGrid/BatchImageGrid'; import BatchImageGrid from './ImageGrid/BatchImageGrid';
import GalleryImageGrid from './ImageGrid/GalleryImageGrid'; import GalleryImageGrid from './ImageGrid/GalleryImageGrid';
import IAIButton from 'common/components/IAIButton';
import { FaImages, FaServer } from 'react-icons/fa';
import { galleryViewChanged } from '../store/gallerySlice';
const selector = createSelector( const selector = createSelector(
[stateSelector], [stateSelector],
(state) => { (state) => {
const { selectedBoardId } = state.gallery; const { selectedBoardId, galleryView } = state.gallery;
return { return {
selectedBoardId, selectedBoardId,
galleryView,
}; };
}, },
defaultSelectorOptions defaultSelectorOptions
@ -26,10 +40,19 @@ const selector = createSelector(
const ImageGalleryContent = () => { const ImageGalleryContent = () => {
const resizeObserverRef = useRef<HTMLDivElement>(null); const resizeObserverRef = useRef<HTMLDivElement>(null);
const galleryGridRef = useRef<HTMLDivElement>(null); const galleryGridRef = useRef<HTMLDivElement>(null);
const { selectedBoardId } = useAppSelector(selector); const { selectedBoardId, galleryView } = useAppSelector(selector);
const dispatch = useAppDispatch();
const { isOpen: isBoardListOpen, onToggle: onToggleBoardList } = const { isOpen: isBoardListOpen, onToggle: onToggleBoardList } =
useDisclosure(); useDisclosure();
const handleClickImages = useCallback(() => {
dispatch(galleryViewChanged('images'));
}, [dispatch]);
const handleClickAssets = useCallback(() => {
dispatch(galleryViewChanged('assets'));
}, [dispatch]);
return ( return (
<VStack <VStack
sx={{ sx={{
@ -48,11 +71,11 @@ const ImageGalleryContent = () => {
gap: 2, gap: 2,
}} }}
> >
<GallerySettingsPopover />
<GalleryBoardName <GalleryBoardName
isOpen={isBoardListOpen} isOpen={isBoardListOpen}
onToggle={onToggleBoardList} onToggle={onToggleBoardList}
/> />
<GallerySettingsPopover />
<GalleryPinButton /> <GalleryPinButton />
</Flex> </Flex>
<Box> <Box>
@ -60,6 +83,36 @@ const ImageGalleryContent = () => {
</Box> </Box>
</Box> </Box>
<Flex ref={galleryGridRef} direction="column" gap={2} h="full" w="full"> <Flex ref={galleryGridRef} direction="column" gap={2} h="full" w="full">
<Flex
sx={{
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
}}
>
<Tabs
index={galleryView === 'images' ? 0 : 1}
variant="line"
size="sm"
sx={{ w: 'full' }}
>
<TabList>
<Tab
onClick={handleClickImages}
sx={{ borderTopRadius: 'base', w: 'full' }}
>
Images
</Tab>
<Tab
onClick={handleClickAssets}
sx={{ borderTopRadius: 'base', w: 'full' }}
>
Assets
</Tab>
</TabList>
</Tabs>
</Flex>
{selectedBoardId === 'batch' ? ( {selectedBoardId === 'batch' ? (
<BatchImageGrid /> <BatchImageGrid />
) : ( ) : (

View File

@ -19,6 +19,7 @@ import GalleryImage from './GalleryImage';
import ImageGridItemContainer from './ImageGridItemContainer'; import ImageGridItemContainer from './ImageGridItemContainer';
import ImageGridListContainer from './ImageGridListContainer'; import ImageGridListContainer from './ImageGridListContainer';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors'; import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import { useBoardTotal } from 'services/api/hooks/useBoardTotal';
const overlayScrollbarsConfig: UseOverlayScrollbarsParams = { const overlayScrollbarsConfig: UseOverlayScrollbarsParams = {
defer: true, defer: true,
@ -40,7 +41,10 @@ const GalleryImageGrid = () => {
const [initialize, osInstance] = useOverlayScrollbars( const [initialize, osInstance] = useOverlayScrollbars(
overlayScrollbarsConfig overlayScrollbarsConfig
); );
const selectedBoardId = useAppSelector(
(state) => state.gallery.selectedBoardId
);
const { currentViewTotal } = useBoardTotal(selectedBoardId);
const queryArgs = useAppSelector(selectListImagesBaseQueryArgs); const queryArgs = useAppSelector(selectListImagesBaseQueryArgs);
const { currentData, isFetching, isSuccess, isError } = const { currentData, isFetching, isSuccess, isError } =
@ -49,19 +53,23 @@ const GalleryImageGrid = () => {
const [listImages] = useLazyListImagesQuery(); const [listImages] = useLazyListImagesQuery();
const areMoreAvailable = useMemo(() => { const areMoreAvailable = useMemo(() => {
if (!currentData) { if (!currentData || !currentViewTotal) {
return false; return false;
} }
return currentData.ids.length < currentData.total; return currentData.ids.length < currentViewTotal;
}, [currentData]); }, [currentData, currentViewTotal]);
const handleLoadMoreImages = useCallback(() => { const handleLoadMoreImages = useCallback(() => {
if (!areMoreAvailable) {
return;
}
listImages({ listImages({
...queryArgs, ...queryArgs,
offset: currentData?.ids.length ?? 0, offset: currentData?.ids.length ?? 0,
limit: IMAGE_LIMIT, limit: IMAGE_LIMIT,
}); });
}, [listImages, queryArgs, currentData?.ids.length]); }, [areMoreAvailable, listImages, queryArgs, currentData?.ids.length]);
useEffect(() => { useEffect(() => {
// Initialize the gallery's custom scrollbar // Initialize the gallery's custom scrollbar

View File

@ -4,7 +4,6 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { import {
IMAGE_LIMIT, IMAGE_LIMIT,
imageSelected, imageSelected,
selectImagesById,
} from 'features/gallery/store/gallerySlice'; } from 'features/gallery/store/gallerySlice';
import { clamp, isEqual } from 'lodash-es'; import { clamp, isEqual } from 'lodash-es';
import { useCallback } from 'react'; import { useCallback } from 'react';
@ -53,8 +52,8 @@ export const nextPrevImageButtonsSelector = createSelector(
const prevImageIndex = clamp(currentImageIndex - 1, 0, images.length - 1); const prevImageIndex = clamp(currentImageIndex - 1, 0, images.length - 1);
const nextImageId = images[nextImageIndex].image_name; const nextImageId = images[nextImageIndex]?.image_name;
const prevImageId = images[prevImageIndex].image_name; const prevImageId = images[prevImageIndex]?.image_name;
const nextImage = selectors.selectById(data, nextImageId); const nextImage = selectors.selectById(data, nextImageId);
const prevImage = selectors.selectById(data, prevImageId); const prevImage = selectors.selectById(data, prevImageId);
@ -65,7 +64,7 @@ export const nextPrevImageButtonsSelector = createSelector(
isOnFirstImage: currentImageIndex === 0, isOnFirstImage: currentImageIndex === 0,
isOnLastImage: isOnLastImage:
!isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1, !isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1,
areMoreImagesAvailable: data?.total ?? 0 > imagesLength, areMoreImagesAvailable: (data?.total ?? 0) > imagesLength,
isFetching: status === 'pending', isFetching: status === 'pending',
nextImage, nextImage,
prevImage, prevImage,

View File

@ -2,11 +2,11 @@ import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store'; import { RootState } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { ListImagesArgs } from 'services/api/endpoints/images'; import { ListImagesArgs } from 'services/api/endpoints/images';
import { INITIAL_IMAGE_LIMIT } from './gallerySlice';
import { import {
getBoardIdQueryParamForBoard, ASSETS_CATEGORIES,
getCategoriesQueryParamForBoard, IMAGE_CATEGORIES,
} from './util'; INITIAL_IMAGE_LIMIT,
} from './gallerySlice';
export const gallerySelector = (state: RootState) => state.gallery; export const gallerySelector = (state: RootState) => state.gallery;
@ -19,14 +19,13 @@ export const selectLastSelectedImage = createSelector(
export const selectListImagesBaseQueryArgs = createSelector( export const selectListImagesBaseQueryArgs = createSelector(
[(state: RootState) => state], [(state: RootState) => state],
(state) => { (state) => {
const { selectedBoardId } = state.gallery; const { selectedBoardId, galleryView } = state.gallery;
const categories =
const categories = getCategoriesQueryParamForBoard(selectedBoardId); galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES;
const board_id = getBoardIdQueryParamForBoard(selectedBoardId);
const listImagesBaseQueryArgs: ListImagesArgs = { const listImagesBaseQueryArgs: ListImagesArgs = {
board_id: selectedBoardId ?? 'none',
categories, categories,
board_id,
offset: 0, offset: 0,
limit: INITIAL_IMAGE_LIMIT, limit: INITIAL_IMAGE_LIMIT,
is_intermediate: false, is_intermediate: false,

View File

@ -14,20 +14,17 @@ export const ASSETS_CATEGORIES: ImageCategory[] = [
export const INITIAL_IMAGE_LIMIT = 100; export const INITIAL_IMAGE_LIMIT = 100;
export const IMAGE_LIMIT = 20; export const IMAGE_LIMIT = 20;
// export type GalleryView = 'images' | 'assets'; export type GalleryView = 'images' | 'assets';
export type BoardId = // export type BoardId = 'no_board' | (string & Record<never, never>);
| 'images' export type BoardId = string | undefined;
| 'assets'
| 'no_board'
| 'batch'
| (string & Record<never, never>);
type GalleryState = { type GalleryState = {
selection: string[]; selection: string[];
shouldAutoSwitch: boolean; shouldAutoSwitch: boolean;
autoAddBoardId: string | null; autoAddBoardId: string | undefined;
galleryImageMinimumWidth: number; galleryImageMinimumWidth: number;
selectedBoardId: BoardId; selectedBoardId: BoardId;
galleryView: GalleryView;
batchImageNames: string[]; batchImageNames: string[];
isBatchEnabled: boolean; isBatchEnabled: boolean;
}; };
@ -35,9 +32,10 @@ type GalleryState = {
export const initialGalleryState: GalleryState = { export const initialGalleryState: GalleryState = {
selection: [], selection: [],
shouldAutoSwitch: true, shouldAutoSwitch: true,
autoAddBoardId: null, autoAddBoardId: undefined,
galleryImageMinimumWidth: 96, galleryImageMinimumWidth: 96,
selectedBoardId: 'images', selectedBoardId: undefined,
galleryView: 'images',
batchImageNames: [], batchImageNames: [],
isBatchEnabled: false, isBatchEnabled: false,
}; };
@ -96,6 +94,7 @@ export const gallerySlice = createSlice({
}, },
boardIdSelected: (state, action: PayloadAction<BoardId>) => { boardIdSelected: (state, action: PayloadAction<BoardId>) => {
state.selectedBoardId = action.payload; state.selectedBoardId = action.payload;
state.galleryView = 'images';
}, },
isBatchEnabledChanged: (state, action: PayloadAction<boolean>) => { isBatchEnabledChanged: (state, action: PayloadAction<boolean>) => {
state.isBatchEnabled = action.payload; state.isBatchEnabled = action.payload;
@ -125,9 +124,15 @@ export const gallerySlice = createSlice({
state.batchImageNames = []; state.batchImageNames = [];
state.selection = []; state.selection = [];
}, },
autoAddBoardIdChanged: (state, action: PayloadAction<string | null>) => { autoAddBoardIdChanged: (
state,
action: PayloadAction<string | undefined>
) => {
state.autoAddBoardId = action.payload; state.autoAddBoardId = action.payload;
}, },
galleryViewChanged: (state, action: PayloadAction<GalleryView>) => {
state.galleryView = action.payload;
},
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder.addMatcher( builder.addMatcher(
@ -135,10 +140,11 @@ export const gallerySlice = createSlice({
(state, action) => { (state, action) => {
const deletedBoardId = action.meta.arg.originalArgs; const deletedBoardId = action.meta.arg.originalArgs;
if (deletedBoardId === state.selectedBoardId) { if (deletedBoardId === state.selectedBoardId) {
state.selectedBoardId = 'images'; state.selectedBoardId = undefined;
state.galleryView = 'images';
} }
if (deletedBoardId === state.autoAddBoardId) { if (deletedBoardId === state.autoAddBoardId) {
state.autoAddBoardId = null; state.autoAddBoardId = undefined;
} }
} }
); );
@ -151,7 +157,7 @@ export const gallerySlice = createSlice({
} }
if (!boards.map((b) => b.board_id).includes(state.autoAddBoardId)) { if (!boards.map((b) => b.board_id).includes(state.autoAddBoardId)) {
state.autoAddBoardId = null; state.autoAddBoardId = undefined;
} }
} }
); );
@ -170,6 +176,7 @@ export const {
imagesAddedToBatch, imagesAddedToBatch,
imagesRemovedFromBatch, imagesRemovedFromBatch,
autoAddBoardIdChanged, autoAddBoardIdChanged,
galleryViewChanged,
} = gallerySlice.actions; } = gallerySlice.actions;
export default gallerySlice.reducer; export default gallerySlice.reducer;

View File

@ -1,6 +1,11 @@
import { SYSTEM_BOARDS } from 'services/api/endpoints/images'; import { ListImagesArgs, SYSTEM_BOARDS } from 'services/api/endpoints/images';
import { ASSETS_CATEGORIES, BoardId, IMAGE_CATEGORIES } from './gallerySlice'; import {
import { ImageCategory } from 'services/api/types'; ASSETS_CATEGORIES,
BoardId,
GalleryView,
IMAGE_CATEGORIES,
} from './gallerySlice';
import { ImageCategory, ImageDTO } from 'services/api/types';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
export const getCategoriesQueryParamForBoard = ( export const getCategoriesQueryParamForBoard = (
@ -20,16 +25,11 @@ export const getCategoriesQueryParamForBoard = (
export const getBoardIdQueryParamForBoard = ( export const getBoardIdQueryParamForBoard = (
board_id: BoardId board_id: BoardId
): string | undefined => { ): string | null => {
if (board_id === 'no_board') { if (board_id === undefined) {
return 'none'; return 'none';
} }
// system boards besides 'no_board'
if (SYSTEM_BOARDS.includes(board_id)) {
return undefined;
}
// user boards // user boards
return board_id; return board_id;
}; };
@ -52,3 +52,10 @@ export const getBoardIdFromBoardAndCategoriesQueryParam = (
return board_id ?? 'UNKNOWN_BOARD'; return board_id ?? 'UNKNOWN_BOARD';
}; };
export const getCategories = (imageDTO: ImageDTO) => {
if (IMAGE_CATEGORIES.includes(imageDTO.image_category)) {
return IMAGE_CATEGORIES;
}
return ASSETS_CATEGORIES;
};

View File

@ -1,52 +1,36 @@
import { ImageDTO, OffsetPaginatedResults_ImageDTO_ } from 'services/api/types'; import { api } from '..';
import { ApiFullTagDescription, LIST_TAG, api } from '..';
import { paths } from '../schema';
import { BoardId } from 'features/gallery/store/gallerySlice';
type ListBoardImagesArg =
paths['/api/v1/board_images/{board_id}']['get']['parameters']['path'] &
paths['/api/v1/board_images/{board_id}']['get']['parameters']['query'];
type AddImageToBoardArg =
paths['/api/v1/board_images/']['post']['requestBody']['content']['application/json'];
type RemoveImageFromBoardArg =
paths['/api/v1/board_images/']['delete']['requestBody']['content']['application/json'];
export const boardImagesApi = api.injectEndpoints({ export const boardImagesApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
/** /**
* Board Images Queries * Board Images Queries
*/ */
// listBoardImages: build.query<
listBoardImages: build.query< // OffsetPaginatedResults_ImageDTO_,
OffsetPaginatedResults_ImageDTO_, // ListBoardImagesArg
ListBoardImagesArg // >({
>({ // query: ({ board_id, offset, limit }) => ({
query: ({ board_id, offset, limit }) => ({ // url: `board_images/${board_id}`,
url: `board_images/${board_id}`, // method: 'GET',
method: 'GET', // }),
}), // providesTags: (result, error, arg) => {
providesTags: (result, error, arg) => { // // any list of boardimages
// any list of boardimages // const tags: ApiFullTagDescription[] = [
const tags: ApiFullTagDescription[] = [ // { type: 'BoardImage', id: `${arg.board_id}_${LIST_TAG}` },
{ type: 'BoardImage', id: `${arg.board_id}_${LIST_TAG}` }, // ];
]; // if (result) {
// // and individual tags for each boardimage
if (result) { // tags.push(
// and individual tags for each boardimage // ...result.items.map(({ board_id, image_name }) => ({
tags.push( // type: 'BoardImage' as const,
...result.items.map(({ board_id, image_name }) => ({ // id: `${board_id}_${image_name}`,
type: 'BoardImage' as const, // }))
id: `${board_id}_${image_name}`, // );
})) // }
); // return tags;
} // },
// }),
return tags;
},
}),
}), }),
}); });
export const { useListBoardImagesQuery } = boardImagesApi; // export const { useListBoardImagesQuery } = boardImagesApi;

View File

@ -109,10 +109,25 @@ export const boardsApi = api.injectEndpoints({
deleteBoard: build.mutation<DeleteBoardResult, string>({ deleteBoard: build.mutation<DeleteBoardResult, string>({
query: (board_id) => ({ url: `boards/${board_id}`, method: 'DELETE' }), query: (board_id) => ({ url: `boards/${board_id}`, method: 'DELETE' }),
invalidatesTags: (result, error, arg) => [ invalidatesTags: (result, error, board_id) => [
{ type: 'Board', id: arg }, { type: 'Board', id: LIST_TAG },
// invalidate the 'No Board' cache // invalidate the 'No Board' cache
{ type: 'ImageList', id: getListImagesUrl({ board_id: 'none' }) }, {
type: 'ImageList',
id: getListImagesUrl({
board_id: 'none',
categories: IMAGE_CATEGORIES,
}),
},
{
type: 'ImageList',
id: getListImagesUrl({
board_id: 'none',
categories: ASSETS_CATEGORIES,
}),
},
{ type: 'BoardImagesTotal', id: 'none' },
{ type: 'BoardAssetsTotal', id: 'none' },
], ],
async onQueryStarted(board_id, { dispatch, queryFulfilled, getState }) { async onQueryStarted(board_id, { dispatch, queryFulfilled, getState }) {
/** /**
@ -167,24 +182,14 @@ export const boardsApi = api.injectEndpoints({
'listImages', 'listImages',
queryArgs, queryArgs,
(draft) => { (draft) => {
const oldCount = imagesAdapter const oldTotal = draft.total;
.getSelectors()
.selectTotal(draft);
const newState = imagesAdapter.updateMany(draft, updates); const newState = imagesAdapter.updateMany(draft, updates);
const newCount = imagesAdapter const delta = newState.total - oldTotal;
.getSelectors() draft.total = draft.total + delta;
.selectTotal(newState);
draft.total = Math.max(
draft.total - (oldCount - newCount),
0
);
} }
) )
); );
}); });
// after deleting a board, select the 'All Images' board
dispatch(boardIdSelected('images'));
} catch { } catch {
//no-op //no-op
} }
@ -197,9 +202,24 @@ export const boardsApi = api.injectEndpoints({
method: 'DELETE', method: 'DELETE',
params: { include_images: true }, params: { include_images: true },
}), }),
invalidatesTags: (result, error, arg) => [ invalidatesTags: (result, error, board_id) => [
{ type: 'Board', id: arg }, { type: 'Board', id: LIST_TAG },
{ type: 'ImageList', id: getListImagesUrl({ board_id: 'none' }) }, {
type: 'ImageList',
id: getListImagesUrl({
board_id: 'none',
categories: IMAGE_CATEGORIES,
}),
},
{
type: 'ImageList',
id: getListImagesUrl({
board_id: 'none',
categories: ASSETS_CATEGORIES,
}),
},
{ type: 'BoardImagesTotal', id: 'none' },
{ type: 'BoardAssetsTotal', id: 'none' },
], ],
async onQueryStarted(board_id, { dispatch, queryFulfilled, getState }) { async onQueryStarted(board_id, { dispatch, queryFulfilled, getState }) {
/** /**
@ -231,27 +251,17 @@ export const boardsApi = api.injectEndpoints({
'listImages', 'listImages',
queryArgs, queryArgs,
(draft) => { (draft) => {
const oldCount = imagesAdapter const oldTotal = draft.total;
.getSelectors()
.selectTotal(draft);
const newState = imagesAdapter.removeMany( const newState = imagesAdapter.removeMany(
draft, draft,
deleted_images deleted_images
); );
const newCount = imagesAdapter const delta = newState.total - oldTotal;
.getSelectors() draft.total = draft.total + delta;
.selectTotal(newState);
draft.total = Math.max(
draft.total - (oldCount - newCount),
0
);
} }
) )
); );
}); });
// after deleting a board, select the 'All Images' board
dispatch(boardIdSelected('images'));
} catch { } catch {
//no-op //no-op
} }

View File

@ -6,18 +6,17 @@ import {
BoardId, BoardId,
IMAGE_CATEGORIES, IMAGE_CATEGORIES,
} from 'features/gallery/store/gallerySlice'; } from 'features/gallery/store/gallerySlice';
import { omit } from 'lodash-es'; import { getCategories } from 'features/gallery/store/util';
import queryString from 'query-string'; import queryString from 'query-string';
import { ApiFullTagDescription, api } from '..'; import { ApiFullTagDescription, api } from '..';
import { components, paths } from '../schema'; import { components, paths } from '../schema';
import { import {
ImageCategory, ImageCategory,
ImageChanges,
ImageDTO, ImageDTO,
OffsetPaginatedResults_ImageDTO_, OffsetPaginatedResults_ImageDTO_,
PostUploadAction, PostUploadAction,
} from '../types'; } from '../types';
import { getCacheAction } from './util'; import { getIsImageInDateRange } from './util';
export type ListImagesArgs = NonNullable< export type ListImagesArgs = NonNullable<
paths['/api/v1/images/']['get']['parameters']['query'] paths['/api/v1/images/']['get']['parameters']['query']
@ -155,6 +154,42 @@ export const imagesApi = api.injectEndpoints({
}, },
keepUnusedDataFor: 86400, // 24 hours keepUnusedDataFor: 86400, // 24 hours
}), }),
getBoardImagesTotal: build.query<number, string | undefined>({
query: (board_id) => ({
url: getListImagesUrl({
board_id: board_id ?? 'none',
categories: IMAGE_CATEGORIES,
is_intermediate: false,
limit: 0,
offset: 0,
}),
method: 'GET',
}),
providesTags: (result, error, arg) => [
{ type: 'BoardImagesTotal', id: arg ?? 'none' },
],
transformResponse: (response: OffsetPaginatedResults_ImageDTO_) => {
return response.total;
},
}),
getBoardAssetsTotal: build.query<number, string | undefined>({
query: (board_id) => ({
url: getListImagesUrl({
board_id: board_id ?? 'none',
categories: ASSETS_CATEGORIES,
is_intermediate: false,
limit: 0,
offset: 0,
}),
method: 'GET',
}),
providesTags: (result, error, arg) => [
{ type: 'BoardAssetsTotal', id: arg ?? 'none' },
],
transformResponse: (response: OffsetPaginatedResults_ImageDTO_) => {
return response.total;
},
}),
clearIntermediates: build.mutation<number, void>({ clearIntermediates: build.mutation<number, void>({
query: () => ({ url: `images/clear-intermediates`, method: 'POST' }), query: () => ({ url: `images/clear-intermediates`, method: 'POST' }),
invalidatesTags: ['IntermediatesCount'], invalidatesTags: ['IntermediatesCount'],
@ -164,56 +199,42 @@ export const imagesApi = api.injectEndpoints({
url: `images/${image_name}`, url: `images/${image_name}`,
method: 'DELETE', method: 'DELETE',
}), }),
invalidatesTags: (result, error, arg) => [ invalidatesTags: (result, error, { board_id }) => [
{ type: 'Image', id: arg.image_name }, { type: 'BoardImagesTotal', id: board_id ?? 'none' },
{ type: 'BoardAssetsTotal', id: board_id ?? 'none' },
], ],
async onQueryStarted(imageDTO, { dispatch, queryFulfilled }) { async onQueryStarted(imageDTO, { dispatch, queryFulfilled }) {
/** /**
* Cache changes for `deleteImage`: * Cache changes for `deleteImage`:
* - *remove* from "All Images" / "All Assets" * - NOT POSSIBLE: *remove* from getImageDTO
* - IF it has a board: * - $cache = [board_id|no_board]/[images|assets]
* - THEN *remove* from it's own board * - *remove* from $cache
* - ELSE *remove* from "No Board"
*/ */
const { image_name, board_id, image_category } = imageDTO; const { image_name, board_id } = imageDTO;
// Figure out the `listImages` caches that we need to update // Store patches so we can undo if the query fails
// That means constructing the possible query args that are serialized into the cache key... const patches: PatchCollection[] = [];
const removeFromCacheKeys: ListImagesArgs[] = [];
// determine `categories`, i.e. do we update "All Images" or "All Assets" // determine `categories`, i.e. do we update "All Images" or "All Assets"
const categories = IMAGE_CATEGORIES.includes(image_category) // $cache = [board_id|no_board]/[images|assets]
? IMAGE_CATEGORIES const categories = getCategories(imageDTO);
: ASSETS_CATEGORIES;
// remove from "All Images" // *remove* from $cache
removeFromCacheKeys.push({ categories }); patches.push(
dispatch(
if (board_id) { imagesApi.util.updateQueryData(
// remove from it's own board 'listImages',
removeFromCacheKeys.push({ board_id }); { board_id: board_id ?? 'none', categories },
} else { (draft) => {
// remove from "No Board" const oldTotal = draft.total;
removeFromCacheKeys.push({ board_id: 'none' }); const newState = imagesAdapter.removeOne(draft, image_name);
} const delta = newState.total - oldTotal;
draft.total = draft.total + delta;
const patches: PatchCollection[] = []; }
removeFromCacheKeys.forEach((cacheKey) => {
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'listImages',
cacheKey,
(draft) => {
imagesAdapter.removeOne(draft, image_name);
draft.total = Math.max(draft.total - 1, 0);
}
)
) )
); )
}); );
try { try {
await queryFulfilled; await queryFulfilled;
@ -222,122 +243,168 @@ export const imagesApi = api.injectEndpoints({
} }
}, },
}), }),
updateImage: build.mutation< /**
* Change an image's `is_intermediate` property.
*/
changeImageIsIntermediate: build.mutation<
ImageDTO, ImageDTO,
{ { imageDTO: ImageDTO; is_intermediate: boolean }
imageDTO: ImageDTO;
// For now, we will not allow image categories to change
changes: Omit<ImageChanges, 'image_category'>;
}
>({ >({
query: ({ imageDTO, changes }) => ({ query: ({ imageDTO, is_intermediate }) => ({
url: `images/${imageDTO.image_name}`, url: `images/${imageDTO.image_name}`,
method: 'PATCH', method: 'PATCH',
body: changes, body: { is_intermediate },
}), }),
invalidatesTags: (result, error, { imageDTO }) => [ invalidatesTags: (result, error, { imageDTO }) => [
{ type: 'Image', id: imageDTO.image_name }, { type: 'BoardImagesTotal', id: imageDTO.board_id ?? 'none' },
{ type: 'BoardAssetsTotal', id: imageDTO.board_id ?? 'none' },
], ],
async onQueryStarted( async onQueryStarted(
{ imageDTO: oldImageDTO, changes: _changes }, { imageDTO, is_intermediate },
{ dispatch, queryFulfilled, getState } { dispatch, queryFulfilled, getState }
) { ) {
// let's be extra-sure we do not accidentally change categories
const changes = omit(_changes, 'image_category');
/** /**
* Cache changes for "updateImage": * Cache changes for `changeImageIsIntermediate`:
* - *update* "getImageDTO" cache * - *update* getImageDTO
* - for "All Images" || "All Assets": * - $cache = [board_id|no_board]/[images|assets]
* - IF it is not already in the cache * - IF it is being changed to an intermediate:
* - THEN *add* it to "All Images" / "All Assets" and update the total * - remove from $cache
* - ELSE *update* it * - ELSE (it is being changed to a non-intermediate):
* - IF the image has a board: * - IF it eligible for insertion into existing $cache:
* - THEN *update* it's own board * - *upsert* to $cache
* - ELSE *update* the "No Board" board
*/ */
// Store patches so we can undo if the query fails
const patches: PatchCollection[] = []; const patches: PatchCollection[] = [];
const { image_name, board_id, image_category, is_intermediate } =
oldImageDTO;
const isChangingFromIntermediate = changes.is_intermediate === false; // *update* getImageDTO
// do not add intermediates to gallery cache
if (is_intermediate && !isChangingFromIntermediate) {
return;
}
// determine `categories`, i.e. do we update "All Images" or "All Assets"
const categories = IMAGE_CATEGORIES.includes(image_category)
? IMAGE_CATEGORIES
: ASSETS_CATEGORIES;
// update `getImageDTO` cache
patches.push( patches.push(
dispatch( dispatch(
imagesApi.util.updateQueryData( imagesApi.util.updateQueryData(
'getImageDTO', 'getImageDTO',
image_name, imageDTO.image_name,
(draft) => { (draft) => {
Object.assign(draft, changes); Object.assign(draft, { is_intermediate });
} }
) )
) )
); );
// Update the "All Image" or "All Assets" board // $cache = [board_id|no_board]/[images|assets]
const queryArgsToUpdate: ListImagesArgs[] = [{ categories }]; const categories = getCategories(imageDTO);
// IF the image has a board: if (is_intermediate) {
if (board_id) { // IF it is being changed to an intermediate:
// THEN update it's own board // remove from $cache
queryArgsToUpdate.push({ board_id }); patches.push(
dispatch(
imagesApi.util.updateQueryData(
'listImages',
{ board_id: imageDTO.board_id ?? 'none', categories },
(draft) => {
const oldTotal = draft.total;
const newState = imagesAdapter.removeOne(
draft,
imageDTO.image_name
);
const delta = newState.total - oldTotal;
draft.total = draft.total + delta;
}
)
)
);
} else { } else {
// ELSE update the "No Board" board // ELSE (it is being changed to a non-intermediate):
queryArgsToUpdate.push({ board_id: 'none' }); const queryArgs = {
} board_id: imageDTO.board_id ?? 'none',
categories,
};
queryArgsToUpdate.forEach((queryArg) => { const currentCache = imagesApi.endpoints.listImages.select(queryArgs)(
const { data } = imagesApi.endpoints.listImages.select(queryArg)(
getState() getState()
); );
const cacheAction = getCacheAction(data, oldImageDTO); // IF it eligible for insertion into existing $cache
// "eligible" means either:
// - The cache is fully populated, with all images in the db cached
// OR
// - The image's `created_at` is within the range of the cached images
if (['update', 'add'].includes(cacheAction)) { const isCacheFullyPopulated =
currentCache.data &&
currentCache.data.ids.length >= currentCache.data.total;
const isInDateRange = getIsImageInDateRange(
currentCache.data,
imageDTO
);
if (isCacheFullyPopulated || isInDateRange) {
// *upsert* to $cache
patches.push( patches.push(
dispatch( dispatch(
imagesApi.util.updateQueryData( imagesApi.util.updateQueryData(
'listImages', 'listImages',
queryArg, queryArgs,
(draft) => { (draft) => {
// One of the common changes is to make a canvas intermediate a non-intermediate, const oldTotal = draft.total;
// i.e. save a canvas image to the gallery. const newState = imagesAdapter.upsertOne(draft, imageDTO);
// If that was the change, need to add the image to the cache instead of updating const delta = newState.total - oldTotal;
// the existing cache entry. draft.total = draft.total + delta;
if (
changes.is_intermediate === false ||
cacheAction === 'add'
) {
// add it to the cache
imagesAdapter.addOne(draft, {
...oldImageDTO,
...changes,
});
draft.total += 1;
} else if (cacheAction === 'update') {
// just update it
imagesAdapter.updateOne(draft, {
id: image_name,
changes,
});
}
} }
) )
) )
); );
} }
}); }
try {
await queryFulfilled;
} catch {
patches.forEach((patchResult) => patchResult.undo());
}
},
}),
/**
* Change an image's `session_id` association.
*/
changeImageSessionId: build.mutation<
ImageDTO,
{ imageDTO: ImageDTO; session_id: string }
>({
query: ({ imageDTO, session_id }) => ({
url: `images/${imageDTO.image_name}`,
method: 'PATCH',
body: { session_id },
}),
invalidatesTags: (result, error, { imageDTO }) => [
{ type: 'BoardImagesTotal', id: imageDTO.board_id ?? 'none' },
{ type: 'BoardAssetsTotal', id: imageDTO.board_id ?? 'none' },
],
async onQueryStarted(
{ imageDTO, session_id },
{ dispatch, queryFulfilled, getState }
) {
/**
* Cache changes for `changeImageSessionId`:
* - *update* getImageDTO
*/
// Store patches so we can undo if the query fails
const patches: PatchCollection[] = [];
// *update* getImageDTO
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'getImageDTO',
imageDTO.image_name,
(draft) => {
Object.assign(draft, { session_id });
}
)
)
);
try { try {
await queryFulfilled; await queryFulfilled;
@ -375,6 +442,15 @@ export const imagesApi = api.injectEndpoints({
{ dispatch, queryFulfilled } { dispatch, queryFulfilled }
) { ) {
try { try {
/**
* NOTE: PESSIMISTIC UPDATE
* Cache changes for `uploadImage`:
* - IF the image is an intermediate:
* - BAIL OUT
* - *add* to `getImageDTO`
* - *add* to no_board/assets
*/
const { data: imageDTO } = await queryFulfilled; const { data: imageDTO } = await queryFulfilled;
if (imageDTO.is_intermediate) { if (imageDTO.is_intermediate) {
@ -382,21 +458,37 @@ export const imagesApi = api.injectEndpoints({
return; return;
} }
// determine `categories`, i.e. do we update "All Images" or "All Assets" // *add* to `getImageDTO`
const categories = IMAGE_CATEGORIES.includes(image_category) dispatch(
? IMAGE_CATEGORIES imagesApi.util.upsertQueryData(
: ASSETS_CATEGORIES; 'getImageDTO',
imageDTO.image_name,
imageDTO
)
);
const queryArg = { categories }; // *add* to no_board/assets
dispatch(
imagesApi.util.updateQueryData(
'listImages',
{ board_id: 'none', categories: ASSETS_CATEGORIES },
(draft) => {
const oldTotal = draft.total;
const newState = imagesAdapter.addOne(draft, imageDTO);
const delta = newState.total - oldTotal;
draft.total = draft.total + delta;
}
)
);
dispatch( dispatch(
imagesApi.util.updateQueryData('listImages', queryArg, (draft) => { imagesApi.util.invalidateTags([
imagesAdapter.addOne(draft, imageDTO); { type: 'BoardImagesTotal', id: imageDTO.board_id ?? 'none' },
draft.total = draft.total + 1; { type: 'BoardAssetsTotal', id: imageDTO.board_id ?? 'none' },
}) ])
); );
} catch { } catch {
// no-op // query failed, no action needed
} }
}, },
}), }),
@ -412,107 +504,103 @@ export const imagesApi = api.injectEndpoints({
body: { board_id, image_name }, body: { board_id, image_name },
}; };
}, },
invalidatesTags: (result, error, arg) => [ invalidatesTags: (result, error, { board_id, imageDTO }) => [
{ type: 'BoardImage' }, { type: 'Board', id: board_id },
{ type: 'Board', id: arg.board_id }, { type: 'BoardImagesTotal', id: board_id },
{ type: 'BoardImagesTotal', id: imageDTO.board_id ?? 'none' },
{ type: 'BoardAssetsTotal', id: board_id },
{ type: 'BoardAssetsTotal', id: imageDTO.board_id ?? 'none' },
], ],
async onQueryStarted( async onQueryStarted(
{ board_id, imageDTO: oldImageDTO }, { board_id, imageDTO },
{ dispatch, queryFulfilled, getState } { dispatch, queryFulfilled, getState }
) { ) {
/** /**
* Cache changes for `addImageToBoard`: * Cache changes for `addImageToBoard`:
* - *update* the `getImageDTO` cache * - *update* getImageDTO
* - *remove* from "No Board" * - IF it has an old board_id:
* - IF the image has an old `board_id`: * - THEN *remove* from old board_id/[images|assets]
* - THEN *remove* from it's old `board_id` * - ELSE *remove* from no_board/[images|assets]
* - IF the image's `created_at` is within the range of the board's cached images * - $cache = board_id/[images|assets]
* - OR the board cache has length of 0 or 1 * - IF it eligible for insertion into existing $cache:
* - THEN *add* it to new `board_id` * - THEN *add* to $cache
*/ */
const { image_name, board_id: old_board_id } = oldImageDTO;
// Figure out the `listImages` caches that we need to update
const removeFromQueryArgs: ListImagesArgs[] = [];
// remove from "No Board"
removeFromQueryArgs.push({ board_id: 'none' });
// remove from old board
if (old_board_id) {
removeFromQueryArgs.push({ board_id: old_board_id });
}
// Store all patch results in case we need to roll back
const patches: PatchCollection[] = []; const patches: PatchCollection[] = [];
const categories = getCategories(imageDTO);
// Updated imageDTO with new board_id // *update* getImageDTO
const newImageDTO = { ...oldImageDTO, board_id };
// Update getImageDTO cache
patches.push( patches.push(
dispatch( dispatch(
imagesApi.util.updateQueryData( imagesApi.util.updateQueryData(
'getImageDTO', 'getImageDTO',
image_name, imageDTO.image_name,
(draft) => { (draft) => {
Object.assign(draft, newImageDTO); Object.assign(draft, { board_id });
} }
) )
) )
); );
// Do the "Remove from" cache updates // *remove* from [no_board|board_id]/[images|assets]
removeFromQueryArgs.forEach((queryArgs) => { patches.push(
dispatch(
imagesApi.util.updateQueryData(
'listImages',
{
board_id: imageDTO.board_id ?? 'none',
categories,
},
(draft) => {
const oldTotal = draft.total;
const newState = imagesAdapter.removeOne(
draft,
imageDTO.image_name
);
const delta = newState.total - oldTotal;
draft.total = draft.total + delta;
}
)
)
);
// $cache = board_id/[images|assets]
const queryArgs = { board_id: board_id ?? 'none', categories };
const currentCache = imagesApi.endpoints.listImages.select(queryArgs)(
getState()
);
// IF it eligible for insertion into existing $cache
// "eligible" means either:
// - The cache is fully populated, with all images in the db cached
// OR
// - The image's `created_at` is within the range of the cached images
const isCacheFullyPopulated =
currentCache.data &&
currentCache.data.ids.length >= currentCache.data.total;
const isInDateRange = getIsImageInDateRange(
currentCache.data,
imageDTO
);
if (isCacheFullyPopulated || isInDateRange) {
// THEN *add* to $cache
patches.push( patches.push(
dispatch( dispatch(
imagesApi.util.updateQueryData( imagesApi.util.updateQueryData(
'listImages', 'listImages',
queryArgs, queryArgs,
(draft) => { (draft) => {
// sanity check const oldTotal = draft.total;
if (draft.ids.includes(image_name)) { const newState = imagesAdapter.addOne(draft, imageDTO);
imagesAdapter.removeOne(draft, image_name); const delta = newState.total - oldTotal;
draft.total = Math.max(draft.total - 1, 0); draft.total = draft.total + delta;
}
} }
) )
) )
); );
});
// We only need to add to the cache if the board is not a system board
if (!SYSTEM_BOARDS.includes(board_id)) {
const queryArgs = { board_id };
const { data } = imagesApi.endpoints.listImages.select(queryArgs)(
getState()
);
const cacheAction = getCacheAction(data, oldImageDTO);
if (['add', 'update'].includes(cacheAction)) {
// Do the "Add to" cache updates
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'listImages',
queryArgs,
(draft) => {
if (cacheAction === 'add') {
imagesAdapter.addOne(draft, newImageDTO);
draft.total += 1;
} else {
imagesAdapter.updateOne(draft, {
id: image_name,
changes: { board_id },
});
}
}
)
)
);
}
} }
try { try {
@ -531,87 +619,97 @@ export const imagesApi = api.injectEndpoints({
body: { board_id, image_name }, body: { board_id, image_name },
}; };
}, },
invalidatesTags: (result, error, arg) => [ invalidatesTags: (result, error, { imageDTO }) => [
{ type: 'BoardImage' }, { type: 'Board', id: imageDTO.board_id },
{ type: 'Board', id: arg.imageDTO.board_id }, { type: 'BoardImagesTotal', id: imageDTO.board_id },
{ type: 'BoardImagesTotal', id: 'none' },
{ type: 'BoardAssetsTotal', id: imageDTO.board_id },
{ type: 'BoardAssetsTotal', id: 'none' },
], ],
async onQueryStarted( async onQueryStarted(
{ imageDTO }, { imageDTO },
{ dispatch, queryFulfilled, getState } { dispatch, queryFulfilled, getState }
) { ) {
/** /**
* Cache changes for `removeImageFromBoard`: * Cache changes for removeImageFromBoard:
* - *update* `getImageDTO` * - *update* getImageDTO
* - IF the image's `created_at` is within the range of the board's cached images * - *remove* from board_id/[images|assets]
* - THEN *add* to "No Board" * - $cache = no_board/[images|assets]
* - *remove* from `old_board_id` * - IF it eligible for insertion into existing $cache:
* - THEN *upsert* to $cache
*/ */
const { image_name, board_id: old_board_id } = imageDTO; const categories = getCategories(imageDTO);
const patches: PatchCollection[] = []; const patches: PatchCollection[] = [];
// Updated imageDTO with new board_id // *update* getImageDTO
const newImageDTO = { ...imageDTO, board_id: undefined };
// Update getImageDTO cache
patches.push( patches.push(
dispatch( dispatch(
imagesApi.util.updateQueryData( imagesApi.util.updateQueryData(
'getImageDTO', 'getImageDTO',
image_name, imageDTO.image_name,
(draft) => { (draft) => {
Object.assign(draft, newImageDTO); Object.assign(draft, { board_id: undefined });
} }
) )
) )
); );
// Remove from old board // *remove* from board_id/[images|assets]
if (old_board_id) { patches.push(
const oldBoardQueryArgs = { board_id: old_board_id }; dispatch(
patches.push( imagesApi.util.updateQueryData(
dispatch( 'listImages',
imagesApi.util.updateQueryData( {
'listImages', board_id: imageDTO.board_id ?? 'none',
oldBoardQueryArgs, categories,
(draft) => { },
// sanity check (draft) => {
if (draft.ids.includes(image_name)) { const oldTotal = draft.total;
imagesAdapter.removeOne(draft, image_name); const newState = imagesAdapter.removeOne(
draft.total = Math.max(draft.total - 1, 0); draft,
} imageDTO.image_name
} );
) const delta = newState.total - oldTotal;
draft.total = draft.total + delta;
}
) )
); )
} );
// Add to "No Board" // $cache = no_board/[images|assets]
const noBoardQueryArgs = { board_id: 'none' }; const queryArgs = { board_id: 'none', categories };
const { data } = imagesApi.endpoints.listImages.select( const currentCache = imagesApi.endpoints.listImages.select(queryArgs)(
noBoardQueryArgs getState()
)(getState()); );
// Check if we need to make any cache changes // IF it eligible for insertion into existing $cache
const cacheAction = getCacheAction(data, imageDTO); // "eligible" means either:
// - The cache is fully populated, with all images in the db cached
// OR
// - The image's `created_at` is within the range of the cached images
if (['add', 'update'].includes(cacheAction)) { const isCacheFullyPopulated =
currentCache.data &&
currentCache.data.ids.length >= currentCache.data.total;
const isInDateRange = getIsImageInDateRange(
currentCache.data,
imageDTO
);
if (isCacheFullyPopulated || isInDateRange) {
// THEN *upsert* to $cache
patches.push( patches.push(
dispatch( dispatch(
imagesApi.util.updateQueryData( imagesApi.util.updateQueryData(
'listImages', 'listImages',
noBoardQueryArgs, queryArgs,
(draft) => { (draft) => {
if (cacheAction === 'add') { const oldTotal = draft.total;
imagesAdapter.addOne(draft, imageDTO); const newState = imagesAdapter.upsertOne(draft, imageDTO);
draft.total += 1; const delta = newState.total - oldTotal;
} else { draft.total = draft.total + delta;
imagesAdapter.updateOne(draft, {
id: image_name,
changes: { board_id: undefined },
});
}
} }
) )
) )
@ -635,7 +733,9 @@ export const {
useGetImageDTOQuery, useGetImageDTOQuery,
useGetImageMetadataQuery, useGetImageMetadataQuery,
useDeleteImageMutation, useDeleteImageMutation,
useUpdateImageMutation, // useUpdateImageMutation,
useGetBoardImagesTotalQuery,
useGetBoardAssetsTotalQuery,
useUploadImageMutation, useUploadImageMutation,
useAddImageToBoardMutation, useAddImageToBoardMutation,
useRemoveImageFromBoardMutation, useRemoveImageFromBoardMutation,

View File

@ -25,27 +25,27 @@ export const getIsImageInDateRange = (
return false; return false;
}; };
/** // /**
* Determines the action we should take when an image may need to be added or updated in a cache. // * Determines the action we should take when an image may need to be added or updated in a cache.
*/ // */
export const getCacheAction = ( // export const getCacheAction = (
data: ImageCache | undefined, // data: ImageCache | undefined,
imageDTO: ImageDTO // imageDTO: ImageDTO
): 'add' | 'update' | 'none' => { // ): 'add' | 'update' | 'none' => {
const isInDateRange = getIsImageInDateRange(data, imageDTO); // const isInDateRange = getIsImageInDateRange(data, imageDTO);
const isCacheFullyPopulated = data && data.total === data.ids.length; // const isCacheFullyPopulated = data && data.total === data.ids.length;
const shouldUpdateCache = // const shouldUpdateCache =
Boolean(isInDateRange) || Boolean(isCacheFullyPopulated); // Boolean(isInDateRange) || Boolean(isCacheFullyPopulated);
const isImageInCache = data && data.ids.includes(imageDTO.image_name); // const isImageInCache = data && data.ids.includes(imageDTO.image_name);
if (shouldUpdateCache && isImageInCache) { // if (shouldUpdateCache && isImageInCache) {
return 'update'; // return 'update';
} // }
if (shouldUpdateCache && !isImageInCache) { // if (shouldUpdateCache && !isImageInCache) {
return 'add'; // return 'add';
} // }
return 'none'; // return 'none';
}; // };

View File

@ -4,19 +4,8 @@ import { useListAllBoardsQuery } from '../endpoints/boards';
export const useBoardName = (board_id: BoardId | null | undefined) => { export const useBoardName = (board_id: BoardId | null | undefined) => {
const { boardName } = useListAllBoardsQuery(undefined, { const { boardName } = useListAllBoardsQuery(undefined, {
selectFromResult: ({ data }) => { selectFromResult: ({ data }) => {
let boardName = ''; const selectedBoard = data?.find((b) => b.board_id === board_id);
if (board_id === 'images') { const boardName = selectedBoard?.board_name || 'Uncategorized';
boardName = 'Images';
} else if (board_id === 'assets') {
boardName = 'Assets';
} else if (board_id === 'no_board') {
boardName = 'No Board';
} else if (board_id === 'batch') {
boardName = 'Batch';
} else {
const selectedBoard = data?.find((b) => b.board_id === board_id);
boardName = selectedBoard?.board_name || 'Unknown Board';
}
return { boardName }; return { boardName };
}, },

View File

@ -1,53 +1,21 @@
import { skipToken } from '@reduxjs/toolkit/dist/query'; import { useAppSelector } from 'app/store/storeHooks';
import { import { BoardId } from 'features/gallery/store/gallerySlice';
ASSETS_CATEGORIES,
BoardId,
IMAGE_CATEGORIES,
INITIAL_IMAGE_LIMIT,
} from 'features/gallery/store/gallerySlice';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { ListImagesArgs, useListImagesQuery } from '../endpoints/images'; import {
useGetBoardAssetsTotalQuery,
useGetBoardImagesTotalQuery,
} from '../endpoints/images';
const baseQueryArg: ListImagesArgs = { export const useBoardTotal = (board_id: BoardId) => {
offset: 0, const galleryView = useAppSelector((state) => state.gallery.galleryView);
limit: INITIAL_IMAGE_LIMIT,
is_intermediate: false, const { data: totalImages } = useGetBoardImagesTotalQuery(board_id);
}; const { data: totalAssets } = useGetBoardAssetsTotalQuery(board_id);
const imagesQueryArg: ListImagesArgs = { const currentViewTotal = useMemo(
categories: IMAGE_CATEGORIES, () => (galleryView === 'images' ? totalImages : totalAssets),
...baseQueryArg, [galleryView, totalAssets, totalImages]
}; );
const assetsQueryArg: ListImagesArgs = { return { totalImages, totalAssets, currentViewTotal };
categories: ASSETS_CATEGORIES,
...baseQueryArg,
};
const noBoardQueryArg: ListImagesArgs = {
board_id: 'none',
...baseQueryArg,
};
export const useBoardTotal = (board_id: BoardId | null | undefined) => {
const queryArg = useMemo(() => {
if (!board_id) {
return;
}
if (board_id === 'images') {
return imagesQueryArg;
} else if (board_id === 'assets') {
return assetsQueryArg;
} else if (board_id === 'no_board') {
return noBoardQueryArg;
} else {
return { board_id, ...baseQueryArg };
}
}, [board_id]);
const { total } = useListImagesQuery(queryArg ?? skipToken, {
selectFromResult: ({ currentData }) => ({ total: currentData?.total }),
});
return total;
}; };

View File

@ -10,6 +10,8 @@ import { $authToken, $baseUrl } from 'services/api/client';
export const tagTypes = [ export const tagTypes = [
'Board', 'Board',
'BoardImagesTotal',
'BoardAssetsTotal',
'Image', 'Image',
'ImageNameList', 'ImageNameList',
'ImageList', 'ImageList',