Skip to content

Commit c90b554

Browse files
authored
Boards UI update and add support for private boards (#6588)
## Summary Update Boards UI in the gallery and adds support for creating and displaying private boards <!--A description of the changes in this PR. Include the kind of change (fix, feature, docs, etc), the "why" and the "how". Screenshots or videos are useful for frontend changes.--> ## Related Issues / Discussions <!--WHEN APPLICABLE: List any related issues or discussions on github or discord. If this PR closes an issue, please use the "Closes #1234" format, so that the issue will be automatically closed when the PR merges.--> ## QA Instructions Can view private boards by setting config.allowPrivateBoards to true <!--WHEN APPLICABLE: Describe how you have tested the changes in this PR. Provide enough detail that a reviewer can reproduce your tests.--> ## Merge Plan <!--WHEN APPLICABLE: Large PRs, or PRs that touch sensitive things like DB schemas, may need some care when merging. For example, a careful rebase by the change author, timing to not interfere with a pending release, or a message to contributors on discord after merging.--> ## Checklist - [ ] _The PR has a short but descriptive title, suitable for a changelog_ - [ ] _Tests added / updated (if applicable)_ - [ ] _Documentation added / updated (if applicable)_
2 parents 4313578 + a79e9ca commit c90b554

File tree

18 files changed

+502
-470
lines changed

18 files changed

+502
-470
lines changed

invokeai/app/api/routers/boards.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class DeleteBoardResult(BaseModel):
3131
)
3232
async def create_board(
3333
board_name: str = Query(description="The name of the board to create"),
34+
is_private: bool = Query(default=False, description="Whether the board is private"),
3435
) -> BoardDTO:
3536
"""Creates a board"""
3637
try:

invokeai/app/services/board_records/board_records_common.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ class BoardRecord(BaseModelExcludeNull):
2424
"""The name of the cover image of the board."""
2525
archived: bool = Field(description="Whether or not the board is archived.")
2626
"""Whether or not the board is archived."""
27+
is_private: Optional[bool] = Field(default=None, description="Whether the board is private.")
28+
"""Whether the board is private."""
2729

2830

2931
def deserialize_board_record(board_dict: dict) -> BoardRecord:
@@ -38,6 +40,7 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord:
3840
updated_at = board_dict.get("updated_at", get_iso_timestamp())
3941
deleted_at = board_dict.get("deleted_at", get_iso_timestamp())
4042
archived = board_dict.get("archived", False)
43+
is_private = board_dict.get("is_private", False)
4144

4245
return BoardRecord(
4346
board_id=board_id,
@@ -47,6 +50,7 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord:
4750
updated_at=updated_at,
4851
deleted_at=deleted_at,
4952
archived=archived,
53+
is_private=is_private,
5054
)
5155

5256

invokeai/frontend/web/public/locales/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@
1717
},
1818
"boards": {
1919
"addBoard": "Add Board",
20+
"addPrivateBoard": "Add Private Board",
21+
"addSharedBoard": "Add Shared Board",
2022
"archiveBoard": "Archive Board",
2123
"archived": "Archived",
2224
"autoAddBoard": "Auto-Add Board",
25+
"boards": "Boards",
2326
"selectedForAutoAdd": "Selected for Auto-Add",
2427
"bottomMessage": "Deleting this board and its images will reset any features currently using them.",
2528
"cancel": "Cancel",
@@ -36,8 +39,10 @@
3639
"movingImagesToBoard_other": "Moving {{count}} images to board:",
3740
"myBoard": "My Board",
3841
"noMatching": "No matching Boards",
42+
"private": "Private",
3943
"searchBoard": "Search Boards...",
4044
"selectBoard": "Select a Board",
45+
"shared": "Shared",
4146
"topMessage": "This board contains images used in the following features:",
4247
"unarchiveBoard": "Unarchive Board",
4348
"uncategorized": "Uncategorized",

invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addArchivedOrDeletedBoardListener.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ export const addArchivedOrDeletedBoardListener = (startAppListening: AppStartLis
1515
matcher: isAnyOf(
1616
// Updating a board may change its archived status
1717
boardsApi.endpoints.updateBoard.matchFulfilled,
18-
// If the selected/auto-add board was deleted from a different session, we'll only know during the list request,
19-
boardsApi.endpoints.listAllBoards.matchFulfilled,
2018
// If a board is deleted, we'll need to reset the auto-add board
2119
imagesApi.endpoints.deleteBoard.matchFulfilled,
2220
imagesApi.endpoints.deleteBoardAndImages.matchFulfilled,

invokeai/frontend/web/src/app/types/invokeai.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export type AppConfig = {
6565
*/
6666
shouldUpdateImagesOnConnect: boolean;
6767
shouldFetchMetadataFromApi: boolean;
68+
allowPrivateBoards: boolean;
6869
disabledTabs: InvokeTabName[];
6970
disabledFeatures: AppFeature[];
7071
disabledSDFeatures: SDFeature[];

invokeai/frontend/web/src/common/components/IAIDropOverlay.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ const IAIDropOverlay = (props: Props) => {
5252
bottom={0.5}
5353
opacity={1}
5454
borderWidth={2}
55-
borderColor={isOver ? 'base.50' : 'base.300'}
56-
borderRadius="lg"
55+
borderColor={isOver ? 'base.300' : 'base.500'}
56+
borderRadius="base"
5757
borderStyle="dashed"
5858
transitionProperty="common"
5959
transitionDuration="0.1s"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Badge } from '@invoke-ai/ui-library';
2+
import { memo } from 'react';
3+
import { useTranslation } from 'react-i18next';
4+
5+
export const AutoAddBadge = memo(() => {
6+
const { t } = useTranslation();
7+
return (
8+
<Badge color="invokeBlue.400" borderColor="invokeBlue.700" borderWidth={1} bg="transparent" flexShrink={0}>
9+
{t('common.auto')}
10+
</Badge>
11+
);
12+
});
13+
14+
AutoAddBadge.displayName = 'AutoAddBadge';

invokeai/frontend/web/src/features/gallery/components/Boards/AutoAddIcon.tsx

Lines changed: 0 additions & 16 deletions
This file was deleted.

invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AddBoardButton.tsx

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,48 @@
11
import { IconButton } from '@invoke-ai/ui-library';
2-
import { memo, useCallback } from 'react';
2+
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
3+
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
4+
import { memo, useCallback, useMemo } from 'react';
35
import { useTranslation } from 'react-i18next';
46
import { PiPlusBold } from 'react-icons/pi';
57
import { useCreateBoardMutation } from 'services/api/endpoints/boards';
68

7-
const AddBoardButton = () => {
9+
type Props = {
10+
isPrivateBoard: boolean;
11+
};
12+
13+
const AddBoardButton = ({ isPrivateBoard }: Props) => {
814
const { t } = useTranslation();
15+
const dispatch = useAppDispatch();
16+
const allowPrivateBoards = useAppSelector((s) => s.config.allowPrivateBoards);
917
const [createBoard, { isLoading }] = useCreateBoardMutation();
10-
const DEFAULT_BOARD_NAME = t('boards.myBoard');
11-
const handleCreateBoard = useCallback(() => {
12-
createBoard(DEFAULT_BOARD_NAME);
13-
}, [createBoard, DEFAULT_BOARD_NAME]);
18+
const label = useMemo(() => {
19+
if (!allowPrivateBoards) {
20+
return t('boards.addBoard');
21+
}
22+
if (isPrivateBoard) {
23+
return t('boards.addPrivateBoard');
24+
}
25+
return t('boards.addSharedBoard');
26+
}, [allowPrivateBoards, isPrivateBoard, t]);
27+
const handleCreateBoard = useCallback(async () => {
28+
try {
29+
const board = await createBoard({ board_name: t('boards.myBoard'), is_private: isPrivateBoard }).unwrap();
30+
dispatch(boardIdSelected({ boardId: board.board_id }));
31+
} catch {
32+
//no-op
33+
}
34+
}, [t, createBoard, isPrivateBoard, dispatch]);
1435

1536
return (
1637
<IconButton
1738
icon={<PiPlusBold />}
1839
isLoading={isLoading}
19-
tooltip={t('boards.addBoard')}
20-
aria-label={t('boards.addBoard')}
40+
tooltip={label}
41+
aria-label={label}
2142
onClick={handleCreateBoard}
22-
size="sm"
43+
size="md"
2344
data-testid="add-board-button"
45+
variant="ghost"
2446
/>
2547
);
2648
};
Lines changed: 96 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1-
import { Collapse, Flex, Grid, GridItem } from '@invoke-ai/ui-library';
1+
import { Collapse, Flex, Icon, Text, useDisclosure } from '@invoke-ai/ui-library';
2+
import { EMPTY_ARRAY } from 'app/store/constants';
23
import { useAppSelector } from 'app/store/storeHooks';
34
import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
45
import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal';
6+
import GallerySettingsPopover from 'features/gallery/components/GallerySettingsPopover/GallerySettingsPopover';
57
import { selectListBoardsQueryArgs } from 'features/gallery/store/gallerySelectors';
68
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
79
import type { CSSProperties } from 'react';
8-
import { memo, useState } from 'react';
10+
import { memo, useMemo, useState } from 'react';
11+
import { useTranslation } from 'react-i18next';
12+
import { PiCaretUpBold } from 'react-icons/pi';
913
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
1014
import type { BoardDTO } from 'services/api/types';
1115

@@ -19,56 +23,112 @@ const overlayScrollbarsStyles: CSSProperties = {
1923
width: '100%',
2024
};
2125

22-
type Props = {
23-
isOpen: boolean;
24-
};
25-
26-
const BoardsList = (props: Props) => {
27-
const { isOpen } = props;
26+
const BoardsList = () => {
2827
const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId);
2928
const boardSearchText = useAppSelector((s) => s.gallery.boardSearchText);
29+
const allowPrivateBoards = useAppSelector((s) => s.config.allowPrivateBoards);
3030
const queryArgs = useAppSelector(selectListBoardsQueryArgs);
3131
const { data: boards } = useListAllBoardsQuery(queryArgs);
32-
const filteredBoards = boardSearchText
33-
? boards?.filter((board) => board.board_name.toLowerCase().includes(boardSearchText.toLowerCase()))
34-
: boards;
3532
const [boardToDelete, setBoardToDelete] = useState<BoardDTO>();
33+
const privateBoardsDisclosure = useDisclosure({ defaultIsOpen: false });
34+
const sharedBoardsDisclosure = useDisclosure({ defaultIsOpen: false });
35+
const { t } = useTranslation();
36+
37+
const { filteredPrivateBoards, filteredSharedBoards } = useMemo(() => {
38+
const filteredBoards = boardSearchText
39+
? boards?.filter((board) => board.board_name.toLowerCase().includes(boardSearchText.toLowerCase()))
40+
: boards;
41+
const filteredPrivateBoards = filteredBoards?.filter((board) => board.is_private) ?? EMPTY_ARRAY;
42+
const filteredSharedBoards = filteredBoards?.filter((board) => !board.is_private) ?? EMPTY_ARRAY;
43+
return { filteredPrivateBoards, filteredSharedBoards };
44+
}, [boardSearchText, boards]);
3645

3746
return (
3847
<>
39-
<Collapse in={isOpen} animateOpacity>
40-
<Flex layerStyle="first" flexDir="column" gap={2} p={2} mt={2} borderRadius="base">
41-
<Flex gap={2} alignItems="center">
42-
<BoardsSearch />
43-
<AddBoardButton />
44-
</Flex>
45-
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayScrollbarsParams.options}>
46-
<Grid
47-
className="list-container"
48-
data-testid="boards-list"
49-
gridTemplateColumns="repeat(auto-fill, minmax(90px, 1fr))"
50-
maxH={346}
51-
>
52-
<GridItem p={1.5} data-testid="no-board">
53-
<NoBoardBoard isSelected={selectedBoardId === 'none'} />
54-
</GridItem>
55-
{filteredBoards &&
56-
filteredBoards.map((board, index) => (
57-
<GridItem key={board.board_id} p={1.5} data-testid={`board-${index}`}>
48+
<Flex layerStyle="first" flexDir="column" borderRadius="base">
49+
<Flex gap={2} alignItems="center" pb={2}>
50+
<BoardsSearch />
51+
<GallerySettingsPopover />
52+
</Flex>
53+
{allowPrivateBoards && (
54+
<>
55+
<Flex w="full" gap={2}>
56+
<Flex
57+
flexGrow={1}
58+
onClick={privateBoardsDisclosure.onToggle}
59+
gap={2}
60+
alignItems="center"
61+
cursor="pointer"
62+
>
63+
<Icon
64+
as={PiCaretUpBold}
65+
boxSize={4}
66+
transform={privateBoardsDisclosure.isOpen ? 'rotate(0deg)' : 'rotate(180deg)'}
67+
transitionProperty="common"
68+
transitionDuration="normal"
69+
color="base.400"
70+
/>
71+
<Text fontSize="md" fontWeight="medium" userSelect="none">
72+
{t('boards.private')}
73+
</Text>
74+
</Flex>
75+
<AddBoardButton isPrivateBoard={true} />
76+
</Flex>
77+
<Collapse in={privateBoardsDisclosure.isOpen} animateOpacity>
78+
<OverlayScrollbarsComponent
79+
defer
80+
style={overlayScrollbarsStyles}
81+
options={overlayScrollbarsParams.options}
82+
>
83+
<Flex direction="column" maxH={346} gap={1}>
84+
{allowPrivateBoards && <NoBoardBoard isSelected={selectedBoardId === 'none'} />}
85+
{filteredPrivateBoards.map((board) => (
5886
<GalleryBoard
5987
board={board}
6088
isSelected={selectedBoardId === board.board_id}
6189
setBoardToDelete={setBoardToDelete}
90+
key={board.board_id}
6291
/>
63-
</GridItem>
64-
))}
65-
</Grid>
66-
</OverlayScrollbarsComponent>
92+
))}
93+
</Flex>
94+
</OverlayScrollbarsComponent>
95+
</Collapse>
96+
</>
97+
)}
98+
<Flex w="full" gap={2}>
99+
<Flex onClick={sharedBoardsDisclosure.onToggle} gap={2} alignItems="center" cursor="pointer" flexGrow={1}>
100+
<Icon
101+
as={PiCaretUpBold}
102+
boxSize={4}
103+
transform={sharedBoardsDisclosure.isOpen ? 'rotate(0deg)' : 'rotate(180deg)'}
104+
transitionProperty="common"
105+
transitionDuration="normal"
106+
color="base.400"
107+
/>
108+
<Text fontSize="md" fontWeight="medium" userSelect="none">
109+
{allowPrivateBoards ? t('boards.shared') : t('boards.boards')}
110+
</Text>
111+
</Flex>
112+
<AddBoardButton isPrivateBoard={false} />
67113
</Flex>
68-
</Collapse>
114+
<Collapse in={sharedBoardsDisclosure.isOpen} animateOpacity>
115+
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayScrollbarsParams.options}>
116+
<Flex direction="column" maxH={346} gap={1}>
117+
{!allowPrivateBoards && <NoBoardBoard isSelected={selectedBoardId === 'none'} />}
118+
{filteredSharedBoards.map((board) => (
119+
<GalleryBoard
120+
board={board}
121+
isSelected={selectedBoardId === board.board_id}
122+
setBoardToDelete={setBoardToDelete}
123+
key={board.board_id}
124+
/>
125+
))}
126+
</Flex>
127+
</OverlayScrollbarsComponent>
128+
</Collapse>
129+
</Flex>
69130
<DeleteBoardModal boardToDelete={boardToDelete} setBoardToDelete={setBoardToDelete} />
70131
</>
71132
);
72133
};
73-
74134
export default memo(BoardsList);

0 commit comments

Comments
 (0)