This commit is contained in:
Mary Hipp 2023-06-14 16:53:01 -04:00 committed by psychedelicious
parent c009f46b00
commit e06c43adc8
11 changed files with 392 additions and 45 deletions

View File

@ -4,6 +4,7 @@ import { appSocketConnected, socketConnected } from 'services/events/actions';
import { receivedPageOfImages } from 'services/thunks/image'; import { receivedPageOfImages } from 'services/thunks/image';
import { receivedModels } from 'services/thunks/model'; import { receivedModels } from 'services/thunks/model';
import { receivedOpenAPISchema } from 'services/thunks/schema'; import { receivedOpenAPISchema } from 'services/thunks/schema';
import { receivedBoards } from '../../../../../../services/thunks/board';
const moduleLog = log.child({ namespace: 'socketio' }); const moduleLog = log.child({ namespace: 'socketio' });
@ -19,6 +20,8 @@ export const addSocketConnectedEventListener = () => {
const { disabledTabs } = config; const { disabledTabs } = config;
dispatch(receivedBoards());
if (!images.ids.length) { if (!images.ids.length) {
dispatch(receivedPageOfImages()); dispatch(receivedPageOfImages());
} }

View File

@ -22,6 +22,7 @@ import uiReducer from 'features/ui/store/uiSlice';
import hotkeysReducer from 'features/ui/store/hotkeysSlice'; import hotkeysReducer from 'features/ui/store/hotkeysSlice';
import modelsReducer from 'features/system/store/modelSlice'; import modelsReducer from 'features/system/store/modelSlice';
import nodesReducer from 'features/nodes/store/nodesSlice'; import nodesReducer from 'features/nodes/store/nodesSlice';
import boardsReducer from 'features/gallery/store/boardSlice';
import { listenerMiddleware } from './middleware/listenerMiddleware'; import { listenerMiddleware } from './middleware/listenerMiddleware';
@ -47,6 +48,7 @@ const allReducers = {
hotkeys: hotkeysReducer, hotkeys: hotkeysReducer,
images: imagesReducer, images: imagesReducer,
controlNet: controlNetReducer, controlNet: controlNetReducer,
boards: boardsReducer,
// session: sessionReducer, // session: sessionReducer,
}; };
@ -65,6 +67,7 @@ const rememberedKeys: (keyof typeof allReducers)[] = [
'system', 'system',
'ui', 'ui',
'controlNet', 'controlNet',
'boards',
// 'hotkeys', // 'hotkeys',
// 'config', // 'config',
]; ];

View File

@ -0,0 +1,101 @@
import { Box, Image, MenuItem, MenuList, Text } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { memo, useCallback, useState } from 'react';
import { FaImage } from 'react-icons/fa';
import { ContextMenu } from 'chakra-ui-contextmenu';
import { useTranslation } from 'react-i18next';
import { ExternalLinkIcon } from '@chakra-ui/icons';
import { useAppToaster } from 'app/components/Toaster';
import { BoardRecord } from 'services/api';
import { EntityId, createSelector } from '@reduxjs/toolkit';
import {
selectFilteredImagesIds,
selectImagesById,
} from '../store/imagesSlice';
import { RootState } from '../../../app/store/store';
import { defaultSelectorOptions } from '../../../app/store/util/defaultMemoizeOptions';
import { useSelector } from 'react-redux';
interface HoverableBoardProps {
board: BoardRecord;
}
/**
* Gallery image component with delete/use all/use seed buttons on hover.
*/
const HoverableBoard = memo(({ board }: HoverableBoardProps) => {
const dispatch = useAppDispatch();
const { board_name, board_id, cover_image_name } = board;
const coverImage = useAppSelector((state) =>
selectImagesById(state, cover_image_name as EntityId)
);
const { t } = useTranslation();
const handleSelectBoard = useCallback(() => {
// dispatch(imageSelected(board_id));
}, []);
return (
<Box sx={{ w: 'full', h: 'full', touchAction: 'none' }}>
<ContextMenu<HTMLDivElement>
menuProps={{ size: 'sm', isLazy: true }}
renderMenu={() => (
<MenuList sx={{ visibility: 'visible !important' }}>
<MenuItem
icon={<ExternalLinkIcon />}
// onClickCapture={handleOpenInNewTab}
>
Sample Menu Item
</MenuItem>
</MenuList>
)}
>
{(ref) => (
<Box
position="relative"
key={board_id}
userSelect="none"
onClick={handleSelectBoard}
ref={ref}
sx={{
display: 'flex',
flexDir: 'column',
justifyContent: 'center',
alignItems: 'center',
w: 'full',
h: 'full',
transition: 'transform 0.2s ease-out',
aspectRatio: '1/1',
cursor: 'pointer',
}}
>
<Image
loading="lazy"
// objectFit={
// shouldUseSingleGalleryColumn ? 'contain' : galleryImageObjectFit
// }
draggable={false}
rounded="md"
src={coverImage ? coverImage.thumbnail_url : undefined}
fallback={<FaImage />}
sx={{
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
}}
/>
<Text textAlign="center">{board_name}</Text>
</Box>
)}
</ContextMenu>
</Box>
);
});
HoverableBoard.displayName = 'HoverableBoard';
export default HoverableBoard;

View File

@ -2,7 +2,14 @@ import { Box, Flex, Icon, Image, MenuItem, MenuList } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { imageSelected } from 'features/gallery/store/gallerySlice'; import { imageSelected } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useContext, useState } from 'react'; import { memo, useCallback, useContext, useState } from 'react';
import { FaCheck, FaExpand, FaImage, FaShare, FaTrash } from 'react-icons/fa'; import {
FaCheck,
FaExpand,
FaFolder,
FaImage,
FaShare,
FaTrash,
} from 'react-icons/fa';
import { ContextMenu } from 'chakra-ui-contextmenu'; import { ContextMenu } from 'chakra-ui-contextmenu';
import { import {
resizeAndScaleCanvas, resizeAndScaleCanvas,
@ -168,6 +175,10 @@ const HoverableImage = memo((props: HoverableImageProps) => {
// dispatch(setIsLightboxOpen(true)); // dispatch(setIsLightboxOpen(true));
}; };
const handleAddToFolder = useCallback(() => {
// dispatch(addImageToFolder(image));
}, []);
const handleOpenInNewTab = () => { const handleOpenInNewTab = () => {
window.open(image.image_url, '_blank'); window.open(image.image_url, '_blank');
}; };
@ -244,6 +255,9 @@ const HoverableImage = memo((props: HoverableImageProps) => {
{t('parameters.sendToUnifiedCanvas')} {t('parameters.sendToUnifiedCanvas')}
</MenuItem> </MenuItem>
)} )}
<MenuItem icon={<FaFolder />} onClickCapture={handleAddToFolder}>
Add to Folder
</MenuItem>
<MenuItem <MenuItem
sx={{ color: 'error.300' }} sx={{ color: 'error.300' }}
icon={<FaTrash />} icon={<FaTrash />}

View File

@ -20,6 +20,7 @@ import {
setGalleryImageObjectFit, setGalleryImageObjectFit,
setShouldAutoSwitchToNewImages, setShouldAutoSwitchToNewImages,
setShouldUseSingleGalleryColumn, setShouldUseSingleGalleryColumn,
setGalleryView,
} from 'features/gallery/store/gallerySlice'; } from 'features/gallery/store/gallerySlice';
import { togglePinGalleryPanel } from 'features/ui/store/uiSlice'; import { togglePinGalleryPanel } from 'features/ui/store/uiSlice';
import { useOverlayScrollbars } from 'overlayscrollbars-react'; import { useOverlayScrollbars } from 'overlayscrollbars-react';
@ -36,7 +37,7 @@ import {
} from 'react'; } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs'; import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs';
import { FaImage, FaServer, FaWrench } from 'react-icons/fa'; import { FaFolder, FaImage, FaPlus, FaServer, FaWrench } from 'react-icons/fa';
import { MdPhotoLibrary } from 'react-icons/md'; import { MdPhotoLibrary } from 'react-icons/md';
import HoverableImage from './HoverableImage'; import HoverableImage from './HoverableImage';
@ -53,22 +54,39 @@ import {
selectImagesAll, selectImagesAll,
} from '../store/imagesSlice'; } from '../store/imagesSlice';
import { receivedPageOfImages } from 'services/thunks/image'; import { receivedPageOfImages } from 'services/thunks/image';
import { boardSelector } from '../store/boardSelectors';
import { BoardRecord, ImageDTO } from '../../../services/api';
import { isBoardRecord, isImageDTO } from '../../../services/types/guards';
import HoverableBoard from './HoverableBoard';
import IAIInput from '../../../common/components/IAIInput';
import { boardCreated } from '../../../services/thunks/board';
const categorySelector = createSelector( const itemSelector = createSelector(
[(state: RootState) => state], [(state: RootState) => state],
(state) => { (state) => {
const { images } = state; const { images, boards, gallery } = state;
let items: Array<ImageDTO | BoardRecord> = [];
let areMoreAvailable = false;
let isLoading = true;
if (gallery.galleryView === 'images' || gallery.galleryView === 'assets') {
const { categories } = images; const { categories } = images;
const allImages = selectImagesAll(state); const allImages = selectImagesAll(state);
const filteredImages = allImages.filter((i) => items = allImages.filter((i) => categories.includes(i.image_category));
categories.includes(i.image_category) areMoreAvailable = items.length < images.total;
); isLoading = images.isLoading;
} else if (gallery.galleryView === 'boards') {
items = Object.values(boards.entities) as BoardRecord[];
areMoreAvailable = items.length < boards.total;
isLoading = boards.isLoading;
}
return { return {
images: filteredImages, items,
isLoading: images.isLoading, isLoading,
areMoreImagesAvailable: filteredImages.length < images.total, areMoreAvailable,
categories: images.categories, categories: images.categories,
}; };
}, },
@ -76,18 +94,21 @@ const categorySelector = createSelector(
); );
const mainSelector = createSelector( const mainSelector = createSelector(
[gallerySelector, uiSelector], [gallerySelector, uiSelector, boardSelector],
(gallery, ui) => { (gallery, ui, boardState) => {
const { const {
galleryImageMinimumWidth, galleryImageMinimumWidth,
galleryImageObjectFit, galleryImageObjectFit,
shouldAutoSwitchToNewImages, shouldAutoSwitchToNewImages,
shouldUseSingleGalleryColumn, shouldUseSingleGalleryColumn,
selectedImage, selectedImage,
galleryView,
} = gallery; } = gallery;
const { shouldPinGallery } = ui; const { shouldPinGallery } = ui;
const { entities: boards } = boardState;
return { return {
shouldPinGallery, shouldPinGallery,
galleryImageMinimumWidth, galleryImageMinimumWidth,
@ -95,6 +116,8 @@ const mainSelector = createSelector(
shouldAutoSwitchToNewImages, shouldAutoSwitchToNewImages,
shouldUseSingleGalleryColumn, shouldUseSingleGalleryColumn,
selectedImage, selectedImage,
galleryView,
boards,
}; };
}, },
defaultSelectorOptions defaultSelectorOptions
@ -126,21 +149,23 @@ const ImageGalleryContent = () => {
shouldAutoSwitchToNewImages, shouldAutoSwitchToNewImages,
shouldUseSingleGalleryColumn, shouldUseSingleGalleryColumn,
selectedImage, selectedImage,
galleryView,
boards,
} = useAppSelector(mainSelector); } = useAppSelector(mainSelector);
const { images, areMoreImagesAvailable, isLoading, categories } = const { items, areMoreAvailable, isLoading, categories } =
useAppSelector(categorySelector); useAppSelector(itemSelector);
const handleLoadMoreImages = useCallback(() => { const handleLoadMoreImages = useCallback(() => {
dispatch(receivedPageOfImages()); dispatch(receivedPageOfImages());
}, [dispatch]); }, [dispatch]);
const handleEndReached = useMemo(() => { const handleEndReached = useMemo(() => {
if (areMoreImagesAvailable && !isLoading) { if (areMoreAvailable && !isLoading) {
return handleLoadMoreImages; return handleLoadMoreImages;
} }
return undefined; return undefined;
}, [areMoreImagesAvailable, handleLoadMoreImages, isLoading]); }, [areMoreAvailable, handleLoadMoreImages, isLoading]);
const handleChangeGalleryImageMinimumWidth = (v: number) => { const handleChangeGalleryImageMinimumWidth = (v: number) => {
dispatch(setGalleryImageMinimumWidth(v)); dispatch(setGalleryImageMinimumWidth(v));
@ -172,12 +197,24 @@ const ImageGalleryContent = () => {
const handleClickImagesCategory = useCallback(() => { const handleClickImagesCategory = useCallback(() => {
dispatch(imageCategoriesChanged(IMAGE_CATEGORIES)); dispatch(imageCategoriesChanged(IMAGE_CATEGORIES));
dispatch(setGalleryView('images'));
}, [dispatch]); }, [dispatch]);
const handleClickAssetsCategory = useCallback(() => { const handleClickAssetsCategory = useCallback(() => {
dispatch(imageCategoriesChanged(ASSETS_CATEGORIES)); dispatch(imageCategoriesChanged(ASSETS_CATEGORIES));
dispatch(setGalleryView('assets'));
}, [dispatch]); }, [dispatch]);
const handleClickBoardsView = useCallback(() => {
dispatch(setGalleryView('boards'));
}, [dispatch]);
const [newBoardName, setNewBoardName] = useState('');
const handleCreateNewBoard = () => {
dispatch(boardCreated({ requestBody: newBoardName }));
};
return ( return (
<Flex <Flex
sx={{ sx={{
@ -198,7 +235,7 @@ const ImageGalleryContent = () => {
tooltip={t('gallery.images')} tooltip={t('gallery.images')}
aria-label={t('gallery.images')} aria-label={t('gallery.images')}
onClick={handleClickImagesCategory} onClick={handleClickImagesCategory}
isChecked={categories === IMAGE_CATEGORIES} isChecked={galleryView === 'images'}
size="sm" size="sm"
icon={<FaImage />} icon={<FaImage />}
/> />
@ -206,12 +243,47 @@ const ImageGalleryContent = () => {
tooltip={t('gallery.assets')} tooltip={t('gallery.assets')}
aria-label={t('gallery.assets')} aria-label={t('gallery.assets')}
onClick={handleClickAssetsCategory} onClick={handleClickAssetsCategory}
isChecked={categories === ASSETS_CATEGORIES} isChecked={galleryView === 'assets'}
size="sm" size="sm"
icon={<FaServer />} icon={<FaServer />}
/> />
<IAIIconButton
tooltip={t('gallery.boards')}
aria-label={t('gallery.boards')}
onClick={handleClickBoardsView}
isChecked={galleryView === 'boards'}
size="sm"
icon={<FaFolder />}
/>
</ButtonGroup> </ButtonGroup>
<Flex gap={2}> <Flex gap={2}>
<IAIPopover
triggerComponent={
<IAIIconButton
tooltip="Add Board"
aria-label="Add Board"
size="sm"
icon={<FaPlus />}
/>
}
>
<Flex direction="column" gap={2}>
<IAIInput
label="Board Name"
placeholder="Board Name"
value={newBoardName}
onChange={(e) => setNewBoardName(e.target.value)}
/>
<IAIButton
size="sm"
onClick={handleCreateNewBoard}
disabled={true}
isLoading={false}
>
Create
</IAIButton>
</Flex>
</IAIPopover>
<IAIPopover <IAIPopover
triggerComponent={ triggerComponent={
<IAIIconButton <IAIIconButton
@ -271,57 +343,75 @@ const ImageGalleryContent = () => {
</Flex> </Flex>
</Flex> </Flex>
<Flex direction="column" gap={2} h="full"> <Flex direction="column" gap={2} h="full">
{images.length || areMoreImagesAvailable ? ( {items.length || areMoreAvailable ? (
<> <>
<Box ref={rootRef} data-overlayscrollbars="" h="100%"> <Box ref={rootRef} data-overlayscrollbars="" h="100%">
{shouldUseSingleGalleryColumn ? ( {shouldUseSingleGalleryColumn ? (
<Virtuoso <Virtuoso
style={{ height: '100%' }} style={{ height: '100%' }}
data={images} data={items}
endReached={handleEndReached} endReached={handleEndReached}
scrollerRef={(ref) => setScrollerRef(ref)} scrollerRef={(ref) => setScrollerRef(ref)}
itemContent={(index, image) => ( itemContent={(index, item) => {
if (isImageDTO(item)) {
return (
<Flex sx={{ pb: 2 }}> <Flex sx={{ pb: 2 }}>
<HoverableImage <HoverableImage
key={`${image.image_name}-${image.thumbnail_url}`} key={`${item.image_name}-${item.thumbnail_url}`}
image={image} image={item}
isSelected={ isSelected={
selectedImage?.image_name === image?.image_name selectedImage?.image_name === item?.image_name
} }
/> />
</Flex> </Flex>
)} );
} else if (isBoardRecord(item)) {
return (
<Flex sx={{ pb: 2 }}>
<HoverableBoard key={item.board_id} board={item} />
</Flex>
);
}
}}
/> />
) : ( ) : (
<VirtuosoGrid <VirtuosoGrid
style={{ height: '100%' }} style={{ height: '100%' }}
data={images} data={items}
endReached={handleEndReached} endReached={handleEndReached}
components={{ components={{
Item: ItemContainer, Item: ItemContainer,
List: ListContainer, List: ListContainer,
}} }}
scrollerRef={setScroller} scrollerRef={setScroller}
itemContent={(index, image) => ( itemContent={(index, item) => {
if (isImageDTO(item)) {
return (
<HoverableImage <HoverableImage
key={`${image.image_name}-${image.thumbnail_url}`} key={`${item.image_name}-${item.thumbnail_url}`}
image={image} image={item}
isSelected={ isSelected={
selectedImage?.image_name === image?.image_name selectedImage?.image_name === item?.image_name
} }
/> />
)} );
} else if (isBoardRecord(item)) {
return (
<HoverableBoard key={item.board_id} board={item} />
);
}
}}
/> />
)} )}
</Box> </Box>
<IAIButton <IAIButton
onClick={handleLoadMoreImages} onClick={handleLoadMoreImages}
isDisabled={!areMoreImagesAvailable} isDisabled={!areMoreAvailable}
isLoading={isLoading} isLoading={isLoading}
loadingText="Loading" loadingText="Loading"
flexShrink={0} flexShrink={0}
> >
{areMoreImagesAvailable {areMoreAvailable
? t('gallery.loadMore') ? t('gallery.loadMore')
: t('gallery.allImagesLoaded')} : t('gallery.allImagesLoaded')}
</IAIButton> </IAIButton>

View File

@ -0,0 +1,3 @@
import { RootState } from 'app/store/store';
export const boardSelector = (state: RootState) => state.boards;

View File

@ -0,0 +1,77 @@
import {
PayloadAction,
Update,
createEntityAdapter,
createSlice,
} from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { BoardRecord } from 'services/api';
import { dateComparator } from 'common/util/dateComparator';
import { receivedBoards } from '../../../services/thunks/board';
export const boardsAdapter = createEntityAdapter<BoardRecord>({
selectId: (board) => board.board_id,
sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at),
});
type AdditionalBoardsState = {
offset: number;
limit: number;
total: number;
isLoading: boolean;
};
export const initialBoardsState =
boardsAdapter.getInitialState<AdditionalBoardsState>({
offset: 0,
limit: 0,
total: 0,
isLoading: false,
});
export type BoardsState = typeof initialBoardsState;
const boardsSlice = createSlice({
name: 'boards',
initialState: initialBoardsState,
reducers: {
boardUpserted: (state, action: PayloadAction<BoardRecord>) => {
boardsAdapter.upsertOne(state, action.payload);
},
boardUpdatedOne: (state, action: PayloadAction<Update<BoardRecord>>) => {
boardsAdapter.updateOne(state, action.payload);
},
boardRemoved: (state, action: PayloadAction<string>) => {
boardsAdapter.removeOne(state, action.payload);
},
},
extraReducers: (builder) => {
builder.addCase(receivedBoards.pending, (state) => {
state.isLoading = true;
});
builder.addCase(receivedBoards.rejected, (state) => {
state.isLoading = false;
});
builder.addCase(receivedBoards.fulfilled, (state, action) => {
state.isLoading = false;
const { items, offset, limit, total } = action.payload;
state.offset = offset;
state.limit = limit;
state.total = total;
boardsAdapter.upsertMany(state, items);
});
},
});
export const {
selectAll: selectBoardsAll,
selectById: selectBoardsById,
selectEntities: selectBoardsEntities,
selectIds: selectBoardsIds,
selectTotal: selectBoardsTotal,
} = boardsAdapter.getSelectors<RootState>((state) => state.boards);
export const { boardUpserted, boardUpdatedOne, boardRemoved } =
boardsSlice.actions;
export default boardsSlice.reducer;

View File

@ -12,6 +12,7 @@ export interface GalleryState {
galleryImageObjectFit: GalleryImageObjectFitType; galleryImageObjectFit: GalleryImageObjectFitType;
shouldAutoSwitchToNewImages: boolean; shouldAutoSwitchToNewImages: boolean;
shouldUseSingleGalleryColumn: boolean; shouldUseSingleGalleryColumn: boolean;
galleryView: 'images' | 'assets' | 'boards';
} }
export const initialGalleryState: GalleryState = { export const initialGalleryState: GalleryState = {
@ -19,6 +20,7 @@ export const initialGalleryState: GalleryState = {
galleryImageObjectFit: 'cover', galleryImageObjectFit: 'cover',
shouldAutoSwitchToNewImages: true, shouldAutoSwitchToNewImages: true,
shouldUseSingleGalleryColumn: false, shouldUseSingleGalleryColumn: false,
galleryView: 'images',
}; };
export const gallerySlice = createSlice({ export const gallerySlice = createSlice({
@ -48,6 +50,12 @@ export const gallerySlice = createSlice({
) => { ) => {
state.shouldUseSingleGalleryColumn = action.payload; state.shouldUseSingleGalleryColumn = action.payload;
}, },
setGalleryView: (
state,
action: PayloadAction<'images' | 'assets' | 'boards'>
) => {
state.galleryView = action.payload;
},
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder.addCase(imageUpserted, (state, action) => { builder.addCase(imageUpserted, (state, action) => {
@ -75,6 +83,7 @@ export const {
setGalleryImageObjectFit, setGalleryImageObjectFit,
setShouldAutoSwitchToNewImages, setShouldAutoSwitchToNewImages,
setShouldUseSingleGalleryColumn, setShouldUseSingleGalleryColumn,
setGalleryView,
} = gallerySlice.actions; } = gallerySlice.actions;
export default gallerySlice.reducer; export default gallerySlice.reducer;

View File

@ -154,3 +154,16 @@ export const selectFilteredImagesIds = createSelector(
.map((i) => i.image_name); .map((i) => i.image_name);
} }
); );
// export const selectImageById = createSelector(
// (state: RootState, imageId) => state,
// (state) => {
// const {
// images: { categories },
// } = state;
// return selectImagesAll(state)
// .filter((i) => categories.includes(i.image_category))
// .map((i) => i.image_name);
// }
// );

View File

@ -0,0 +1,23 @@
import { createAppAsyncThunk } from '../../app/store/storeUtils';
import { BoardsService } from '../api';
/**
* `BoardsService.listBoards()` thunk
*/
export const receivedBoards = createAppAsyncThunk(
'api/receivedBoards',
async (_, { getState }) => {
const response = await BoardsService.listBoards({});
return response;
}
);
type BoardCreatedArg = Parameters<(typeof BoardsService)['createBoard']>[0];
export const boardCreated = createAppAsyncThunk(
'api/boardCreated',
async (arg: BoardCreatedArg) => {
const response = await BoardsService.createBoard(arg);
return response;
}
);

View File

@ -11,6 +11,7 @@ import {
LatentsOutput, LatentsOutput,
ResourceOrigin, ResourceOrigin,
ImageDTO, ImageDTO,
BoardRecord,
} from 'services/api'; } from 'services/api';
export const isImageDTO = (obj: unknown): obj is ImageDTO => { export const isImageDTO = (obj: unknown): obj is ImageDTO => {
@ -29,6 +30,16 @@ export const isImageDTO = (obj: unknown): obj is ImageDTO => {
); );
}; };
export const isBoardRecord = (obj: unknown): obj is BoardRecord => {
return (
isObject(obj) &&
'board_id' in obj &&
isString(obj?.board_id) &&
'board_name' in obj &&
isString(obj?.board_name)
);
};
export const isImageOutput = ( export const isImageOutput = (
output: GraphExecutionState['results'][string] output: GraphExecutionState['results'][string]
): output is ImageOutput => output.type === 'image_output'; ): output is ImageOutput => output.type === 'image_output';