feat(ui): first pass at boards styling

This commit is contained in:
psychedelicious 2023-06-21 17:58:22 +10:00
parent 2489d5459f
commit bd533426fc
11 changed files with 300 additions and 204 deletions

View File

@ -9,7 +9,7 @@ import {
import { useDraggable, useDroppable } from '@dnd-kit/core';
import { useCombinedRefs } from '@dnd-kit/utilities';
import IAIIconButton from 'common/components/IAIIconButton';
import { IAIImageFallback } from 'common/components/IAIImageFallback';
import { IAIImageLoadingFallback } from 'common/components/IAIImageFallback';
import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay';
import { AnimatePresence } from 'framer-motion';
import { ReactElement, SyntheticEvent, useCallback } from 'react';
@ -53,7 +53,7 @@ const IAIDndImage = (props: IAIDndImageProps) => {
isDropDisabled = false,
isDragDisabled = false,
isUploadDisabled = false,
fallback = <IAIImageFallback />,
fallback = <IAIImageLoadingFallback />,
payloadImage,
minSize = 24,
postUploadAction,

View File

@ -1,10 +1,20 @@
import { Flex, FlexProps, Spinner, SpinnerProps } from '@chakra-ui/react';
import {
As,
Flex,
FlexProps,
Icon,
IconProps,
Spinner,
SpinnerProps,
} from '@chakra-ui/react';
import { ReactElement } from 'react';
import { FaImage } from 'react-icons/fa';
type Props = FlexProps & {
spinnerProps?: SpinnerProps;
};
export const IAIImageFallback = (props: Props) => {
export const IAIImageLoadingFallback = (props: Props) => {
const { spinnerProps, ...rest } = props;
const { sx, ...restFlexProps } = rest;
return (
@ -25,3 +35,35 @@ export const IAIImageFallback = (props: Props) => {
</Flex>
);
};
type IAINoImageFallbackProps = {
flexProps?: FlexProps;
iconProps?: IconProps;
as?: As;
};
export const IAINoImageFallback = (props: IAINoImageFallbackProps) => {
const { sx: flexSx, ...restFlexProps } = props.flexProps ?? { sx: {} };
const { sx: iconSx, ...restIconProps } = props.iconProps ?? { sx: {} };
return (
<Flex
sx={{
bg: 'base.900',
opacity: 0.7,
w: 'full',
h: 'full',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'base',
...flexSx,
}}
{...restFlexProps}
>
<Icon
as={props.as ?? FaImage}
sx={{ color: 'base.700', ...iconSx }}
{...restIconProps}
/>
</Flex>
);
};

View File

@ -11,7 +11,7 @@ import IAIDndImage from 'common/components/IAIDndImage';
import { createSelector } from '@reduxjs/toolkit';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { AnimatePresence, motion } from 'framer-motion';
import { IAIImageFallback } from 'common/components/IAIImageFallback';
import { IAIImageLoadingFallback } from 'common/components/IAIImageFallback';
import IAIIconButton from 'common/components/IAIIconButton';
import { FaUndo } from 'react-icons/fa';
import { useGetImageDTOQuery } from 'services/apiSlice';
@ -173,7 +173,7 @@ const ControlNetImagePreview = (props: Props) => {
h: 'full',
}}
>
<IAIImageFallback />
<IAIImageLoadingFallback />
</Box>
)}
{controlImage && (

View File

@ -1,6 +1,5 @@
import { Flex, Icon, Spinner, Text } from '@chakra-ui/react';
import IAIButton from 'common/components/IAIButton';
import { useCallback } from 'react';
import { FaPlus } from 'react-icons/fa';
import { useCreateBoardMutation } from 'services/apiSlice';
const DEFAULT_BOARD_NAME = 'My Board';
@ -13,38 +12,15 @@ const AddBoardButton = () => {
}, [createBoard]);
return (
<Flex
onClick={isLoading ? undefined : handleCreateBoard}
sx={{
flexDir: 'column',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
w: 'full',
h: 'full',
gap: 1,
}}
<IAIButton
isLoading={isLoading}
aria-label="Add Board"
onClick={handleCreateBoard}
size="sm"
sx={{ px: 4 }}
>
<Flex
sx={{
justifyContent: 'center',
alignItems: 'center',
borderWidth: '1px',
borderRadius: 'base',
borderColor: 'base.800',
w: 'full',
h: 'full',
aspectRatio: '1/1',
}}
>
{isLoading ? (
<Spinner />
) : (
<Icon boxSize={8} color="base.700" as={FaPlus} />
)}
</Flex>
<Text sx={{ color: 'base.200', fontSize: 'xs' }}>New Board</Text>
</Flex>
Add Board
</IAIButton>
);
};

View File

@ -2,12 +2,15 @@ import { Flex, Icon, Text } from '@chakra-ui/react';
import { FaImages } from 'react-icons/fa';
import { boardIdSelected } from '../../store/boardSlice';
import { useDispatch } from 'react-redux';
import { IAINoImageFallback } from 'common/components/IAIImageFallback';
import { AnimatePresence } from 'framer-motion';
import { SelectedItemOverlay } from '../SelectedItemOverlay';
const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
const dispatch = useDispatch();
const handleAllImagesBoardClick = () => {
dispatch(boardIdSelected(null));
dispatch(boardIdSelected());
};
return (
@ -19,25 +22,34 @@ const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
cursor: 'pointer',
w: 'full',
h: 'full',
gap: 1,
borderRadius: 'base',
}}
onClick={handleAllImagesBoardClick}
>
<Flex
sx={{
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
borderWidth: '1px',
borderRadius: 'base',
borderColor: isSelected ? 'base.500' : 'base.800',
w: 'full',
h: 'full',
aspectRatio: '1/1',
}}
>
<Icon boxSize={8} color="base.700" as={FaImages} />
<IAINoImageFallback iconProps={{ boxSize: 8 }} as={FaImages} />
<AnimatePresence>
{isSelected && <SelectedItemOverlay />}
</AnimatePresence>
</Flex>
<Text sx={{ color: 'base.200', fontSize: 'xs' }}>All Images</Text>
<Text
sx={{
color: isSelected ? 'base.50' : 'base.200',
fontWeight: isSelected ? 600 : undefined,
fontSize: 'xs',
}}
>
All Images
</Text>
</Flex>
);
};

View File

@ -1,12 +1,11 @@
import {
Box,
Divider,
Collapse,
Flex,
Grid,
IconButton,
Input,
InputGroup,
InputRightElement,
Spacer,
useDisclosure,
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
@ -16,33 +15,36 @@ import {
selectBoardsAll,
setBoardSearchText,
} from 'features/gallery/store/boardSlice';
import { memo, useEffect, useState } from 'react';
import { memo, useState } from 'react';
import HoverableBoard from './HoverableBoard';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import AddBoardButton from './AddBoardButton';
import AllImagesBoard from './AllImagesBoard';
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],
(boards, boardsState) => {
const selectedBoard = boards.find(
(board) => board.board_id === boardsState.selectedBoardId
);
return { selectedBoard, searchText: boardsState.searchText };
// const selectedBoard = boards.find(
// (board) => board.board_id === boardsState.selectedBoardId
// );
// return { selectedBoard, searchText: boardsState.searchText };
const { selectedBoardId, searchText } = boardsState;
return { selectedBoardId, searchText };
},
defaultSelectorOptions
);
const BoardsList = () => {
type Props = {
isOpen: boolean;
};
const BoardsList = (props: Props) => {
const { isOpen } = props;
const dispatch = useAppDispatch();
const { selectedBoard, searchText } = useAppSelector(selector);
const { selectedBoardId, searchText } = useAppSelector(selector);
// const filteredBoards = useSelector(searchBoardsSelector);
const { isOpen, onToggle } = useDisclosure();
const { data } = useListBoardsQuery({ offset: 0, limit: 8 });
@ -64,9 +66,18 @@ const BoardsList = () => {
};
return (
<IAICollapse label="Select Board" isOpen={isOpen} onToggle={onToggle}>
<>
<Box marginBottom="1rem">
<Collapse in={isOpen} animateOpacity>
<Flex
sx={{
flexDir: 'column',
gap: 2,
bg: 'base.800',
borderRadius: 'base',
p: 2,
mt: 2,
}}
>
<Flex sx={{ gap: 2, alignItems: 'center' }}>
<InputGroup>
<Input
placeholder="Search Boards..."
@ -77,11 +88,18 @@ const BoardsList = () => {
/>
{searchText && searchText.length && (
<InputRightElement>
<CloseIcon onClick={clearBoardSearch} cursor="pointer" />
<IconButton
onClick={clearBoardSearch}
size="xs"
variant="ghost"
aria-label="Clear Search"
icon={<CloseIcon boxSize={3} />}
/>
</InputRightElement>
)}
</InputGroup>
</Box>
<AddBoardButton />
</Flex>
<OverlayScrollbarsComponent
defer
style={{ height: '100%', width: '100%' }}
@ -98,29 +116,24 @@ const BoardsList = () => {
className="list-container"
sx={{
gap: 2,
gridTemplateRows: '5rem 5rem',
gridTemplateRows: '5.5rem 5.5rem',
gridAutoFlow: 'column dense',
gridAutoColumns: '4rem',
}}
>
{!searchMode && (
<>
<AddBoardButton />
<AllImagesBoard isSelected={!selectedBoard} />
</>
)}
{!searchMode && <AllImagesBoard isSelected={!selectedBoardId} />}
{filteredBoards &&
filteredBoards.map((board) => (
<HoverableBoard
key={board.board_id}
board={board}
isSelected={selectedBoard?.board_id === board.board_id}
isSelected={selectedBoardId === board.board_id}
/>
))}
</Grid>
</OverlayScrollbarsComponent>
</>
</IAICollapse>
</Flex>
</Collapse>
);
};

View File

@ -1,31 +1,22 @@
import {
Badge,
Box,
Editable,
EditableInput,
EditablePreview,
Flex,
Image,
MenuItem,
MenuList,
Text,
} from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAppDispatch } from 'app/store/storeHooks';
import { memo, useCallback } from 'react';
import { FaTrash } from 'react-icons/fa';
import { FaFolder, FaTrash } from 'react-icons/fa';
import { ContextMenu } from 'chakra-ui-contextmenu';
import { BoardDTO, ImageDTO } from 'services/api';
import { IAIImageFallback } from 'common/components/IAIImageFallback';
import { IAINoImageFallback } from 'common/components/IAIImageFallback';
import { boardIdSelected } from 'features/gallery/store/boardSlice';
import {
boardDeleted,
boardUpdated,
imageAddedToBoard,
} from '../../../../services/thunks/board';
import { selectImagesAll, selectImagesById } from '../../store/imagesSlice';
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 {
useAddImageToBoardMutation,
useDeleteBoardMutation,
@ -33,21 +24,10 @@ import {
useUpdateBoardMutation,
} from 'services/apiSlice';
import { skipToken } from '@reduxjs/toolkit/dist/query';
const coverImageSelector = (imageName: string | undefined) =>
createSelector(
[(state: RootState) => state],
(state) => {
const coverImage = imageName
? selectImagesById(state, imageName)
: undefined;
return {
coverImage,
};
},
defaultSelectorOptions
);
import { useDroppable } from '@dnd-kit/core';
import { AnimatePresence } from 'framer-motion';
import IAIDropOverlay from 'common/components/IAIDropOverlay';
import { SelectedItemOverlay } from '../SelectedItemOverlay';
interface HoverableBoardProps {
board: BoardDTO;
@ -94,6 +74,17 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
[addImageToBoard, board_id]
);
const {
isOver,
setNodeRef,
active: isDropActive,
} = useDroppable({
id: `board_droppable_${board_id}`,
data: {
handleDrop,
},
});
return (
<Box sx={{ touchAction: 'none' }}>
<ContextMenu<HTMLDivElement>
@ -112,7 +103,6 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
>
{(ref) => (
<Flex
position="relative"
key={board_id}
userSelect="none"
ref={ref}
@ -123,69 +113,74 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
cursor: 'pointer',
w: 'full',
h: 'full',
gap: 1,
}}
>
<Flex
ref={setNodeRef}
onClick={handleSelectBoard}
sx={{
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
borderWidth: '1px',
borderRadius: 'base',
borderColor: isSelected ? 'base.500' : 'base.800',
w: 'full',
h: 'full',
aspectRatio: '1/1',
overflow: 'hidden',
}}
>
<IAIDndImage
image={
board.cover_image_name && coverImage ? coverImage : undefined
}
onDrop={handleDrop}
fallback={<IAIImageFallback sx={{ bg: 'none' }} />}
isUploadDisabled={true}
/>
{board.cover_image_name && coverImage?.image_url && (
<Image src={coverImage?.image_url} draggable={false} />
)}
{!(board.cover_image_name && coverImage?.image_url) && (
<IAINoImageFallback iconProps={{ boxSize: 8 }} as={FaFolder} />
)}
<Flex
sx={{
position: 'absolute',
insetInlineEnd: 0,
top: 0,
p: 1,
}}
>
<Badge variant="solid">{board.image_count}</Badge>
</Flex>
<AnimatePresence>
{isSelected && <SelectedItemOverlay />}
</AnimatePresence>
<AnimatePresence>
{isDropActive && <IAIDropOverlay isOver={isOver} />}
</AnimatePresence>
</Flex>
<Editable
defaultValue={board_name}
submitOnBlur={false}
onSubmit={(nextValue) => {
handleUpdateBoardName(nextValue);
}}
>
<EditablePreview
sx={{ color: 'base.200', fontSize: 'xs', textAlign: 'left' }}
noOfLines={1}
/>
<EditableInput
sx={{
color: 'base.200',
fontSize: 'xs',
textAlign: 'left',
borderColor: 'base.500',
<Box sx={{ width: 'full' }}>
<Editable
defaultValue={board_name}
submitOnBlur={false}
onSubmit={(nextValue) => {
handleUpdateBoardName(nextValue);
}}
/>
</Editable>
<Flex
sx={{
justifyContent: 'center',
alignItems: 'center',
pos: 'absolute',
color: 'base.900',
bg: 'accent.300',
borderRadius: 'full',
w: 4,
h: 4,
right: -1,
top: -1,
}}
>
<Text fontSize="2xs">{board.image_count}</Text>
</Flex>
>
<EditablePreview
sx={{
color: isSelected ? 'base.50' : 'base.200',
fontWeight: isSelected ? 600 : undefined,
fontSize: 'xs',
textAlign: 'center',
p: 0,
}}
noOfLines={1}
/>
<EditableInput
sx={{
color: 'base.50',
fontSize: 'xs',
borderColor: 'base.500',
p: 0,
outline: 0,
}}
/>
</Editable>
</Box>
</Flex>
)}
</ContextMenu>

View File

@ -14,7 +14,7 @@ import { useAppToaster } from 'app/components/Toaster';
import { imageSelected } from '../store/gallerySlice';
import IAIDndImage from 'common/components/IAIDndImage';
import { ImageDTO } from 'services/api';
import { IAIImageFallback } from 'common/components/IAIImageFallback';
import { IAIImageLoadingFallback } from 'common/components/IAIImageFallback';
import { RootState } from 'app/store/store';
import { selectImagesById } from '../store/imagesSlice';
import { useGetImageDTOQuery } from 'services/apiSlice';
@ -116,7 +116,7 @@ const CurrentImagePreview = () => {
<IAIDndImage
image={image}
onDrop={handleDrop}
fallback={<IAIImageFallback sx={{ bg: 'none' }} />}
fallback={<IAIImageLoadingFallback sx={{ bg: 'none' }} />}
isUploadDisabled={true}
/>
</Flex>

View File

@ -1,12 +1,15 @@
import {
Box,
Button,
ButtonGroup,
Flex,
FlexProps,
Grid,
Icon,
Text,
VStack,
forwardRef,
useDisclosure,
} from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton';
@ -54,10 +57,10 @@ import {
selectImagesAll,
} from '../store/imagesSlice';
import { receivedPageOfImages } from 'services/thunks/image';
import { boardSelector } from '../store/boardSelectors';
import { boardCreated } from '../../../services/thunks/board';
import BoardsList from './Boards/BoardsList';
import { selectBoardsById } from '../store/boardSlice';
import { boardsSelector, selectBoardsById } from '../store/boardSlice';
import { ChevronUpIcon } from '@chakra-ui/icons';
import { useListAllBoardsQuery } from 'services/apiSlice';
const itemSelector = createSelector(
[(state: RootState) => state],
@ -89,7 +92,7 @@ const itemSelector = createSelector(
);
const mainSelector = createSelector(
[gallerySelector, uiSelector, boardSelector],
[gallerySelector, uiSelector, boardsSelector],
(gallery, ui, boards) => {
const {
galleryImageMinimumWidth,
@ -109,7 +112,7 @@ const mainSelector = createSelector(
shouldUseSingleGalleryColumn,
selectedImage,
galleryView,
boards,
selectedBoardId: boards.selectedBoardId,
};
},
defaultSelectorOptions
@ -142,12 +145,18 @@ const ImageGalleryContent = () => {
shouldUseSingleGalleryColumn,
selectedImage,
galleryView,
boards,
selectedBoardId,
} = useAppSelector(mainSelector);
const { items, areMoreAvailable, isLoading, categories, selectedBoard } =
const { items, areMoreAvailable, isLoading, categories } =
useAppSelector(itemSelector);
const { selectedBoard } = useListAllBoardsQuery(undefined, {
selectFromResult: ({ data }) => ({
selectedBoard: data?.find((b) => b.board_id === selectedBoardId),
}),
});
const handleLoadMoreImages = useCallback(() => {
dispatch(receivedPageOfImages());
}, [dispatch]);
@ -159,6 +168,8 @@ const ImageGalleryContent = () => {
return undefined;
}, [areMoreAvailable, handleLoadMoreImages, isLoading]);
const { isOpen: isBoardListOpen, onToggle } = useDisclosure();
const handleChangeGalleryImageMinimumWidth = (v: number) => {
dispatch(setGalleryImageMinimumWidth(v));
};
@ -197,50 +208,71 @@ const ImageGalleryContent = () => {
dispatch(setGalleryView('assets'));
}, [dispatch]);
const handleClickBoardsView = useCallback(() => {
dispatch(setGalleryView('boards'));
}, [dispatch]);
return (
<Flex
<VStack
sx={{
gap: 2,
flexDirection: 'column',
h: 'full',
w: 'full',
borderRadius: 'base',
}}
>
<Flex
ref={resizeObserverRef}
alignItems="center"
justifyContent="space-between"
gap={1}
>
<ButtonGroup isAttached>
<IAIIconButton
tooltip={t('gallery.images')}
aria-label={t('gallery.images')}
onClick={handleClickImagesCategory}
isChecked={galleryView === 'images'}
<Box sx={{ w: 'full' }}>
<Flex
ref={resizeObserverRef}
sx={{
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
}}
>
<ButtonGroup isAttached>
<IAIIconButton
tooltip={t('gallery.images')}
aria-label={t('gallery.images')}
onClick={handleClickImagesCategory}
isChecked={galleryView === 'images'}
size="sm"
icon={<FaImage />}
/>
<IAIIconButton
tooltip={t('gallery.assets')}
aria-label={t('gallery.assets')}
onClick={handleClickAssetsCategory}
isChecked={galleryView === 'assets'}
size="sm"
icon={<FaServer />}
/>
</ButtonGroup>
<Flex
as={Button}
onClick={onToggle}
size="sm"
icon={<FaImage />}
/>
<IAIIconButton
tooltip={t('gallery.assets')}
aria-label={t('gallery.assets')}
onClick={handleClickAssetsCategory}
isChecked={galleryView === 'assets'}
size="sm"
icon={<FaServer />}
/>
</ButtonGroup>
<Flex>
<Text noOfLines={1}>
{selectedBoard ? selectedBoard.board_name : 'All Images'}
</Text>
</Flex>
<Flex gap={2}>
variant="ghost"
sx={{
w: 'full',
justifyContent: 'center',
alignItems: 'center',
px: 2,
_hover: {
bg: 'base.800',
},
}}
>
<Text
noOfLines={1}
sx={{ w: 'full', color: 'base.200', fontWeight: 600 }}
>
{selectedBoard ? selectedBoard.board_name : 'All Images'}
</Text>
<ChevronUpIcon
sx={{
transform: isBoardListOpen ? 'rotate(0deg)' : 'rotate(180deg)',
transitionProperty: 'common',
transitionDuration: 'normal',
}}
/>
</Flex>
<IAIPopover
triggerComponent={
<IAIIconButton
@ -298,11 +330,11 @@ const ImageGalleryContent = () => {
icon={shouldPinGallery ? <BsPinAngleFill /> : <BsPinAngle />}
/>
</Flex>
</Flex>
<Box>
<BoardsList />
<Box>
<BoardsList isOpen={isBoardListOpen} />
</Box>
</Box>
<Flex direction="column" gap={2} h="full">
<Flex direction="column" gap={2} h="full" w="full">
{items.length || areMoreAvailable ? (
<>
<Box ref={rootRef} data-overlayscrollbars="" h="100%">
@ -378,7 +410,7 @@ const ImageGalleryContent = () => {
</Flex>
)}
</Flex>
</Flex>
</VStack>
);
};

View File

@ -0,0 +1,26 @@
import { motion } from 'framer-motion';
export const SelectedItemOverlay = () => (
<motion.div
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
transition: { duration: 0.1 },
}}
exit={{
opacity: 0,
transition: { duration: 0.1 },
}}
style={{
position: 'absolute',
top: 0,
insetInlineStart: 0,
width: '100%',
height: '100%',
boxShadow: 'inset 0px 0px 0px 2px var(--invokeai-colors-accent-300)',
borderRadius: 'var(--invokeai-radii-base)',
}}
/>
);

View File

@ -10,7 +10,7 @@ import { generationSelector } from 'features/parameters/store/generationSelector
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIDndImage from 'common/components/IAIDndImage';
import { ImageDTO } from 'services/api';
import { IAIImageFallback } from 'common/components/IAIImageFallback';
import { IAIImageLoadingFallback } from 'common/components/IAIImageFallback';
import { useGetImageDTOQuery } from 'services/apiSlice';
import { skipToken } from '@reduxjs/toolkit/dist/query';
@ -65,7 +65,7 @@ const InitialImagePreview = () => {
image={image}
onDrop={handleDrop}
onReset={handleReset}
fallback={<IAIImageFallback sx={{ bg: 'none' }} />}
fallback={<IAIImageLoadingFallback sx={{ bg: 'none' }} />}
postUploadAction={{ type: 'SET_INITIAL_IMAGE' }}
withResetIcon
/>