diff --git a/invokeai/app/api/routers/boards.py b/invokeai/app/api/routers/boards.py index 19c2b330f0..fb09c0b463 100644 --- a/invokeai/app/api/routers/boards.py +++ b/invokeai/app/api/routers/boards.py @@ -32,6 +32,7 @@ class DeleteBoardResult(BaseModel): ) async def create_board( board_name: str = Query(description="The name of the board to create"), + private_board: bool = Query(default=False, description="Whether the board is private"), ) -> BoardDTO: """Creates a board""" try: diff --git a/invokeai/frontend/web/src/app/types/invokeai.ts b/invokeai/frontend/web/src/app/types/invokeai.ts index 21636ada49..6d7416d95d 100644 --- a/invokeai/frontend/web/src/app/types/invokeai.ts +++ b/invokeai/frontend/web/src/app/types/invokeai.ts @@ -65,6 +65,7 @@ export type AppConfig = { */ shouldUpdateImagesOnConnect: boolean; shouldFetchMetadataFromApi: boolean; + allowPrivateBoards: boolean; disabledTabs: InvokeTabName[]; disabledFeatures: AppFeature[]; disabledSDFeatures: SDFeature[]; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx index 2e823ea25b..3f60dabf70 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx @@ -46,11 +46,10 @@ const NoBoardBoard = memo(({ isSelected }: Props) => { ); const { t } = useTranslation(); return ( - + { ref={ref} onClick={handleSelectBoard} w="full" - h="full" position="relative" - justifyContent="center" alignItems="center" borderRadius="base" cursor="pointer" bg="base.800" > - - invoke-ai-logo - + invoke-ai-logo {autoAddBoardId === 'none' && } - {boardName} - + {t('unifiedCanvas.move')}} /> diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListWithPrivate/AddBoardButton.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListWithPrivate/AddBoardButton.tsx new file mode 100644 index 0000000000..67e133ba22 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListWithPrivate/AddBoardButton.tsx @@ -0,0 +1,32 @@ +import { Icon } from '@invoke-ai/ui-library'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiPlusBold } from 'react-icons/pi'; +import { useCreateBoardMutation } from 'services/api/endpoints/boards'; + +type Props = { + privateBoard: boolean; +}; + +const AddBoardButton = ({ privateBoard }: Props) => { + const { t } = useTranslation(); + const [createBoard] = useCreateBoardMutation(); + const DEFAULT_BOARD_NAME = t('boards.myBoard'); + const handleCreateBoard = useCallback(() => { + createBoard({ DEFAULT_BOARD_NAME, privateBoard }); + }, [createBoard, DEFAULT_BOARD_NAME, privateBoard]); + + return ( + + ); +}; + +export default memo(AddBoardButton); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListWithPrivate/BoardTotalsTooltip.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListWithPrivate/BoardTotalsTooltip.tsx new file mode 100644 index 0000000000..b4c89a002d --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListWithPrivate/BoardTotalsTooltip.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from 'react-i18next'; +import { useGetBoardAssetsTotalQuery, useGetBoardImagesTotalQuery } from 'services/api/endpoints/boards'; + +type Props = { + board_id: string; + isArchived: boolean; +}; + +export const BoardTotalsTooltip = ({ board_id, isArchived }: Props) => { + const { t } = useTranslation(); + const { imagesTotal } = useGetBoardImagesTotalQuery(board_id, { + selectFromResult: ({ data }) => { + return { imagesTotal: data?.total ?? 0 }; + }, + }); + const { assetsTotal } = useGetBoardAssetsTotalQuery(board_id, { + selectFromResult: ({ data }) => { + return { assetsTotal: data?.total ?? 0 }; + }, + }); + return `${t('boards.imagesWithCount', { count: imagesTotal })}, ${t('boards.assetsWithCount', { count: assetsTotal })}${isArchived ? ` (${t('boards.archived')})` : ''}`; +}; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListWithPrivate/BoardsListWithPrivate.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListWithPrivate/BoardsListWithPrivate.tsx new file mode 100644 index 0000000000..b87a96f272 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListWithPrivate/BoardsListWithPrivate.tsx @@ -0,0 +1,120 @@ +import { Collapse, Flex, Icon, Text } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants'; +import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal'; +import GallerySettingsPopover from 'features/gallery/components/GallerySettingsPopover/GallerySettingsPopover'; +import { selectListBoardsQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; +import type { CSSProperties } from 'react'; +import { memo, useState } from 'react'; +import { PiCaretUpBold, PiPlusBold } from 'react-icons/pi'; +import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; +import type { BoardDTO } from 'services/api/types'; + +import AddBoardButton from './AddBoardButton'; +import BoardsSearch from './BoardsSearch'; +import GalleryBoard from './GalleryBoard'; +import NoBoardBoard from './NoBoardBoard'; + +const overlayScrollbarsStyles: CSSProperties = { + height: '100%', + width: '100%', +}; + +const BoardsListWithPrivate = () => { + const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId); + const boardSearchText = useAppSelector((s) => s.gallery.boardSearchText); + const queryArgs = useAppSelector(selectListBoardsQueryArgs); + const { data: boards } = useListAllBoardsQuery(queryArgs); + const filteredPrivateBoards = boardSearchText + ? boards?.filter((board) => board.is_private && board.board_name.toLowerCase().includes(boardSearchText.toLowerCase())) + : boards?.filter((board) => board.is_private); + const filteredSharedBoards = boardSearchText + ? boards?.filter((board) => !board.is_private && board.board_name.toLowerCase().includes(boardSearchText.toLowerCase())) + : boards?.filter((board) => !board.is_private); + const [boardToDelete, setBoardToDelete] = useState(); + const [isPrivateBoardsOpen, setIsPrivateBoardsOpen] = useState(true); + const [isSharedBoardsOpen, setIsSharedBoardsOpen] = useState(true); + + return ( + <> + + + + + + + + setIsPrivateBoardsOpen(!isPrivateBoardsOpen)} + gap={2} + alignItems="center" + cursor="pointer" + > + + Private + + + + + + + {filteredPrivateBoards && + filteredPrivateBoards.map((board) => ( + + ))} + + + + setIsSharedBoardsOpen(!isSharedBoardsOpen)} + gap={2} + alignItems="center" + cursor="pointer" + > + + + Shared + + + + + + {filteredSharedBoards && + filteredSharedBoards.map((board) => ( + + ))} + + + + + + + ); +}; + +export default memo(BoardsListWithPrivate); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListWithPrivate/BoardsSearch.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListWithPrivate/BoardsSearch.tsx new file mode 100644 index 0000000000..931c1e6cbb --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListWithPrivate/BoardsSearch.tsx @@ -0,0 +1,66 @@ +import { IconButton, Input, InputGroup, InputRightElement } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice'; +import type { ChangeEvent, KeyboardEvent } from 'react'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiXBold } from 'react-icons/pi'; + +const BoardsSearch = () => { + const dispatch = useAppDispatch(); + const boardSearchText = useAppSelector((s) => s.gallery.boardSearchText); + const { t } = useTranslation(); + + const handleBoardSearch = useCallback( + (searchTerm: string) => { + dispatch(boardSearchTextChanged(searchTerm)); + }, + [dispatch] + ); + + const clearBoardSearch = useCallback(() => { + dispatch(boardSearchTextChanged('')); + }, [dispatch]); + + const handleKeydown = useCallback( + (e: KeyboardEvent) => { + // exit search mode on escape + if (e.key === 'Escape') { + clearBoardSearch(); + } + }, + [clearBoardSearch] + ); + + const handleChange = useCallback( + (e: ChangeEvent) => { + handleBoardSearch(e.target.value); + }, + [handleBoardSearch] + ); + + return ( + + + {boardSearchText && boardSearchText.length && ( + + } + /> + + )} + + ); +}; + +export default memo(BoardsSearch); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListWithPrivate/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListWithPrivate/GalleryBoard.tsx new file mode 100644 index 0000000000..7eaae8ee62 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListWithPrivate/GalleryBoard.tsx @@ -0,0 +1,194 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Box, Editable, EditableInput, EditablePreview, Flex, Icon, Image, Text, Tooltip } from '@invoke-ai/ui-library'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import IAIDroppable from 'common/components/IAIDroppable'; +import SelectionOverlay from 'common/components/SelectionOverlay'; +import type { AddToBoardDropData } from 'features/dnd/types'; +import BoardContextMenu from 'features/gallery/components/Boards/BoardContextMenu'; +import { BoardTotalsTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTotalsTooltip'; +import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice'; +import { memo, useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiArchiveBold, PiImagesSquare } from 'react-icons/pi'; +import { useUpdateBoardMutation } from 'services/api/endpoints/boards'; +import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import type { BoardDTO } from 'services/api/types'; + +const editableInputStyles: SystemStyleObject = { + p: 0, + fontSize: 'md', + w: '100%', +}; + +const ArchivedIcon = () => { + return ( + + + + ); +}; + +interface GalleryBoardProps { + board: BoardDTO; + isSelected: boolean; + setBoardToDelete: (board?: BoardDTO) => void; +} + +const GalleryBoard = ({ board, isSelected, setBoardToDelete }: GalleryBoardProps) => { + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const autoAssignBoardOnClick = useAppSelector((s) => s.gallery.autoAssignBoardOnClick); + + const [isHovered, setIsHovered] = useState(false); + const handleMouseOver = useCallback(() => { + setIsHovered(true); + }, []); + const handleMouseOut = useCallback(() => { + setIsHovered(false); + }, []); + + const { currentData: coverImage } = useGetImageDTOQuery(board.cover_image_name ?? skipToken); + + const { board_name, board_id } = board; + const [localBoardName, setLocalBoardName] = useState(board_name); + + const handleSelectBoard = useCallback(() => { + dispatch(boardIdSelected({ boardId: board_id })); + if (autoAssignBoardOnClick && !board.archived) { + dispatch(autoAddBoardIdChanged(board_id)); + } + }, [board_id, autoAssignBoardOnClick, dispatch, board.archived]); + + const [updateBoard, { isLoading: isUpdateBoardLoading }] = useUpdateBoardMutation(); + + const droppableData: AddToBoardDropData = useMemo( + () => ({ + id: board_id, + actionType: 'ADD_TO_BOARD', + context: { boardId: board_id }, + }), + [board_id] + ); + + const handleSubmit = useCallback( + async (newBoardName: string) => { + // empty strings are not allowed + if (!newBoardName.trim()) { + setLocalBoardName(board_name); + return; + } + + // don't updated the board name if it hasn't changed + if (newBoardName === board_name) { + return; + } + + try { + const { board_name } = await updateBoard({ + board_id, + changes: { board_name: newBoardName }, + }).unwrap(); + + // update local state + setLocalBoardName(board_name); + } catch { + // revert on error + setLocalBoardName(board_name); + } + }, + [board_id, board_name, updateBoard] + ); + + const handleChange = useCallback((newBoardName: string) => { + setLocalBoardName(newBoardName); + }, []); + + return ( + + + + {(ref) => ( + } + openDelay={1000} + > + + + {board.archived && } + {coverImage?.thumbnail_url ? ( + + ) : ( + + + + )} + + + + + + + + + + {board.image_count} images + + {t('unifiedCanvas.move')}} /> + + + )} + + + + ); +}; + +export default memo(GalleryBoard); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListWithPrivate/NoBoardBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListWithPrivate/NoBoardBoard.tsx new file mode 100644 index 0000000000..77563859a7 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListWithPrivate/NoBoardBoard.tsx @@ -0,0 +1,96 @@ +import { Box, Flex, Image, Text, Tooltip } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import IAIDroppable from 'common/components/IAIDroppable'; +import SelectionOverlay from 'common/components/SelectionOverlay'; +import type { RemoveFromBoardDropData } from 'features/dnd/types'; +import { BoardTotalsTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTotalsTooltip'; +import NoBoardBoardContextMenu from 'features/gallery/components/Boards/NoBoardBoardContextMenu'; +import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice'; +import InvokeLogoSVG from 'public/assets/images/invoke-symbol-wht-lrg.svg'; +import { memo, useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useBoardName } from 'services/api/hooks/useBoardName'; + +interface Props { + isSelected: boolean; +} + +const NoBoardBoard = memo(({ isSelected }: Props) => { + const dispatch = useAppDispatch(); + const autoAssignBoardOnClick = useAppSelector((s) => s.gallery.autoAssignBoardOnClick); + const boardName = useBoardName('none'); + const handleSelectBoard = useCallback(() => { + dispatch(boardIdSelected({ boardId: 'none' })); + if (autoAssignBoardOnClick) { + dispatch(autoAddBoardIdChanged('none')); + } + }, [dispatch, autoAssignBoardOnClick]); + const [isHovered, setIsHovered] = useState(false); + + const handleMouseOver = useCallback(() => { + setIsHovered(true); + }, []); + + const handleMouseOut = useCallback(() => { + setIsHovered(false); + }, []); + + const droppableData: RemoveFromBoardDropData = useMemo( + () => ({ + id: 'no_board', + actionType: 'REMOVE_FROM_BOARD', + }), + [] + ); + const { t } = useTranslation(); + return ( + + + + {(ref) => ( + } openDelay={1000}> + + invoke-ai-logo + + {boardName} + + + {t('unifiedCanvas.move')}} /> + + + )} + + + + ); +}); + +NoBoardBoard.displayName = 'HoverableBoard'; + +export default memo(NoBoardBoard); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx index b0b147b510..e21e7508fd 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx @@ -9,6 +9,7 @@ import { PiImagesBold } from 'react-icons/pi'; import { RiServerLine } from 'react-icons/ri'; import BoardsList from './Boards/BoardsList/BoardsList'; +import BoardsListWithPrivate from './Boards/BoardsListWithPrivate/BoardsListWithPrivate'; import GalleryBoardName from './GalleryBoardName'; import GallerySettingsPopover from './GallerySettingsPopover/GallerySettingsPopover'; import GalleryImageGrid from './ImageGrid/GalleryImageGrid'; @@ -18,6 +19,7 @@ import { GallerySearch } from './ImageGrid/GallerySearch'; const ImageGalleryContent = () => { const { t } = useTranslation(); const galleryView = useAppSelector((s) => s.gallery.galleryView); + const allowPrivateBoards = useAppSelector((s) => s.config.allowPrivateBoards); const dispatch = useAppDispatch(); const galleryHeader = useStore($galleryHeader); const { isOpen: isBoardListOpen, onToggle: onToggleBoardList } = useDisclosure({ defaultIsOpen: true }); @@ -42,15 +44,21 @@ const ImageGalleryContent = () => { gap={2} > {galleryHeader} - - - - - + {true ? ( - + + + ) : ( + + + + + + + + - + )} diff --git a/invokeai/frontend/web/src/features/system/store/configSlice.ts b/invokeai/frontend/web/src/features/system/store/configSlice.ts index 7d26dbd34c..8901365556 100644 --- a/invokeai/frontend/web/src/features/system/store/configSlice.ts +++ b/invokeai/frontend/web/src/features/system/store/configSlice.ts @@ -18,6 +18,7 @@ const initialConfigState: AppConfig = { isLocal: true, shouldUpdateImagesOnConnect: false, shouldFetchMetadataFromApi: false, + allowPrivateBoards: false, disabledTabs: [], disabledFeatures: ['lightbox', 'faceRestore', 'batches'], disabledSDFeatures: ['variation', 'symmetry', 'hires', 'perlinNoise', 'noiseThreshold'], diff --git a/invokeai/frontend/web/src/services/api/endpoints/boards.ts b/invokeai/frontend/web/src/services/api/endpoints/boards.ts index 177aa0e340..026ffdfa56 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/boards.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/boards.ts @@ -1,5 +1,5 @@ import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; -import type { BoardDTO, ListBoardsArgs, OffsetPaginatedResults_ImageDTO_, UpdateBoardArg } from 'services/api/types'; +import type { BoardDTO, CreateBoardArg, ListBoardsArgs, OffsetPaginatedResults_ImageDTO_, UpdateBoardArg } from 'services/api/types'; import { getListImagesUrl } from 'services/api/util'; import type { ApiTagDescription } from '..'; @@ -87,11 +87,11 @@ export const boardsApi = api.injectEndpoints({ * Boards Mutations */ - createBoard: build.mutation({ - query: (board_name) => ({ + createBoard: build.mutation({ + query: ({ board_name, private_board }) => ({ url: buildBoardsUrl(), method: 'POST', - params: { board_name }, + params: { board_name, private_board }, }), invalidatesTags: [{ type: 'Board', id: LIST_TAG }], }), diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 162bdf6abc..d5c857cefd 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -11,6 +11,10 @@ export type ListBoardsArgs = NonNullable