Compare commits

...

20 Commits

Author SHA1 Message Date
704cfd8ff5 wip attempt to rewrite to use no adapter 2023-07-13 15:32:02 +10:00
2990fa23fe wip 2023-07-13 15:31:44 +10:00
58cb5fefd0 feat(ui): wip again sry lol 2023-07-13 15:31:15 +10:00
2ef5919475 feat(ui): wip again sry 2023-07-13 15:31:15 +10:00
7f07528b08 feat(ui): wip sry 2023-07-13 15:31:15 +10:00
a2f944a657 feat(db,api): changes to db and api to support batch operations
will squash and describe all changes later sry
2023-07-13 15:30:54 +10:00
0317cc158a feat(ui): organise gallery slice 2023-07-13 15:30:54 +10:00
8648332b4f chore(ui): regen types 2023-07-13 15:30:54 +10:00
c2aee42fa3 fix(ui): fix board changes invalidating image tags
Caused a bazillion extraneous network requests
2023-07-13 15:30:54 +10:00
a77f6b0c18 feat(ui): hide noisy rtk query redux actions 2023-07-13 15:30:54 +10:00
8771e32ed2 fix(ui): fixes deleting image in use @ nodes resets node templates 2023-07-13 15:30:54 +10:00
5e1ed63076 fix(ui): fix IAIDraggable/IAIDroppable absolute positioning 2023-07-13 15:30:54 +10:00
cad358dc9a feat(db,api): list images board_id="none" gets images without a board 2023-07-13 15:30:54 +10:00
8501ca0843 feat(ui): improve IAIDndImage performance
`dnd-kit` has a problem where, when drag events start and stop, every item that uses the library rerenders. This occurs due to its use of context.

The dnd library needs to listen for pointer events to handle dragging. Because our images are both clickable (selectable) and draggable, every time you click an image, the dnd necessarily sees this event, its context updates and all other dnd-enabled components rerender.

With a lot of images in gallery and/or batch manager, this leads to some jank.

There is an open PR to address this: https://github.com/clauderic/dnd-kit/pull/1096

But unfortunately, the maintainer hasn't accepted any changes for a few months, and its not clear if this will be merged any time soon :/

This change simply extracts the draggable and droppable logic out of IAIDndImage into their own minimal components. Now only these need to rerender when the dnd context is changed. The rerenders are far less impactful now.

Hopefully the linked PR is accepted and we get even more efficient dnd functionality in the future.

Also changed dnd activation constraint to distance (currently 10px) instead of delay and updated the stacking context of IAIDndImage subcomponents so that the reset and upload buttons still work.
2023-07-13 15:30:54 +10:00
560a59123a feat(ui): improve IAIDndImage performance
`dnd-kit` has a problem where, when drag events start and stop, every item that uses the library rerenders. This occurs due to its use of context.

The dnd library needs to listen for pointer events to handle dragging. Because our images are both clickable (selectable) and draggable, every time you click an image, the dnd necessarily sees this event, its context updates and all other dnd-enabled components rerender.

With a lot of images in gallery and/or batch manager, this leads to some jank.

There is an open PR to address this: https://github.com/clauderic/dnd-kit/pull/1096

But unfortunately, the maintainer hasn't accepted any changes for a few months, and its not clear if this will be merged any time soon :/

This change simply extracts the draggable and droppable logic out of IAIDndImage into their own minimal components. Now only these need to rerender when the dnd context is changed. The rerenders are far less impactful now.

Hopefully the linked PR is accepted and we get even more efficient dnd functionality in the future.
2023-07-13 15:30:54 +10:00
62b700b908 feat(ui): fix listeners for adding selection to board via dnd 2023-07-13 15:30:54 +10:00
9aedf84ac2 fix: fix rebase conflicts 2023-07-13 15:30:54 +10:00
a08179bf34 feat(api,ui): wip batch image actions 2023-07-13 15:30:54 +10:00
0b9aaf1b0b feat(ui): wip multi image ops 2023-07-13 15:30:54 +10:00
da98f281ee feat(api): implement delete many images & add many images to board
Add new routes & high-level service methods for each operation.
2023-07-13 15:30:54 +10:00
79 changed files with 3062 additions and 1563 deletions

View File

@ -1,9 +1,9 @@
from fastapi import Body, HTTPException, Path, Query from fastapi import Body, HTTPException, Path
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from invokeai.app.services.board_record_storage import BoardRecord, BoardChanges
from invokeai.app.services.image_record_storage import OffsetPaginatedResults from invokeai.app.models.image import (AddManyImagesToBoardResult,
from invokeai.app.services.models.board_record import BoardDTO GetAllBoardImagesForBoardResult,
from invokeai.app.services.models.image_record import ImageDTO RemoveManyImagesFromBoardResult)
from ..dependencies import ApiDependencies from ..dependencies import ApiDependencies
@ -11,7 +11,7 @@ board_images_router = APIRouter(prefix="/v1/board_images", tags=["boards"])
@board_images_router.post( @board_images_router.post(
"/", "/{board_id}",
operation_id="create_board_image", operation_id="create_board_image",
responses={ responses={
201: {"description": "The image was added to a board successfully"}, 201: {"description": "The image was added to a board successfully"},
@ -19,16 +19,19 @@ board_images_router = APIRouter(prefix="/v1/board_images", tags=["boards"])
status_code=201, status_code=201,
) )
async def create_board_image( async def create_board_image(
board_id: str = Body(description="The id of the board to add to"), board_id: str = Path(description="The id of the board to add to"),
image_name: str = Body(description="The name of the image to add"), image_name: str = Body(description="The name of the image to add"),
): ):
"""Creates a board_image""" """Creates a board_image"""
try: try:
result = ApiDependencies.invoker.services.board_images.add_image_to_board(board_id=board_id, image_name=image_name) result = ApiDependencies.invoker.services.board_images.add_image_to_board(
board_id=board_id, image_name=image_name
)
return result return result
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail="Failed to add to board") raise HTTPException(status_code=500, detail="Failed to add to board")
@board_images_router.delete( @board_images_router.delete(
"/", "/",
operation_id="remove_board_image", operation_id="remove_board_image",
@ -38,32 +41,78 @@ async def create_board_image(
status_code=201, status_code=201,
) )
async def remove_board_image( async def remove_board_image(
board_id: str = Body(description="The id of the board"), image_name: str = Body(
image_name: str = Body(description="The name of the image to remove"), description="The name of the image to remove from its board"
),
): ):
"""Deletes a board_image""" """Deletes a board_image"""
try: try:
result = ApiDependencies.invoker.services.board_images.remove_image_from_board(board_id=board_id, image_name=image_name) result = ApiDependencies.invoker.services.board_images.remove_image_from_board(
image_name=image_name
)
return result return result
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail="Failed to update board") raise HTTPException(status_code=500, detail="Failed to update board")
@board_images_router.get( @board_images_router.get(
"/{board_id}", "/{board_id}",
operation_id="list_board_images", operation_id="get_all_board_images_for_board",
response_model=OffsetPaginatedResults[ImageDTO], response_model=GetAllBoardImagesForBoardResult,
) )
async def list_board_images( async def get_all_board_images_for_board(
board_id: str = Path(description="The id of the board"), board_id: str = Path(description="The id of the board"),
offset: int = Query(default=0, description="The page offset"), ) -> GetAllBoardImagesForBoardResult:
limit: int = Query(default=10, description="The number of boards per page"), """Gets all image names for a board"""
) -> OffsetPaginatedResults[ImageDTO]:
"""Gets a list of images for a board"""
results = ApiDependencies.invoker.services.board_images.get_images_for_board( result = (
board_id, ApiDependencies.invoker.services.board_images.get_all_board_images_for_board(
board_id,
)
)
return result
@board_images_router.patch(
"/{board_id}/images",
operation_id="create_multiple_board_images",
responses={
201: {"description": "The images were added to the board successfully"},
},
status_code=201,
)
async def create_multiple_board_images(
board_id: str = Path(description="The id of the board"),
image_names: list[str] = Body(
description="The names of the images to add to the board"
),
) -> AddManyImagesToBoardResult:
"""Add many images to a board"""
results = ApiDependencies.invoker.services.board_images.add_many_images_to_board(
board_id, image_names
) )
return results return results
@board_images_router.post(
"/images",
operation_id="delete_multiple_board_images",
responses={
201: {"description": "The images were removed from their boards successfully"},
},
status_code=201,
)
async def delete_multiple_board_images(
image_names: list[str] = Body(
description="The names of the images to remove from their boards, if they have one"
),
) -> RemoveManyImagesFromBoardResult:
"""Remove many images from their boards, if they have one"""
results = (
ApiDependencies.invoker.services.board_images.remove_many_images_from_board(
image_names
)
)
return results

View File

@ -1,11 +1,13 @@
from typing import Optional, Union from typing import Optional, Union
from fastapi import Body, HTTPException, Path, Query from fastapi import Body, HTTPException, Path, Query
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from invokeai.app.models.image import DeleteManyImagesResult
from invokeai.app.services.board_record_storage import BoardChanges from invokeai.app.services.board_record_storage import BoardChanges
from invokeai.app.services.image_record_storage import OffsetPaginatedResults from invokeai.app.services.image_record_storage import OffsetPaginatedResults
from invokeai.app.services.models.board_record import BoardDTO from invokeai.app.services.models.board_record import BoardDTO
from ..dependencies import ApiDependencies from ..dependencies import ApiDependencies
boards_router = APIRouter(prefix="/v1/boards", tags=["boards"]) boards_router = APIRouter(prefix="/v1/boards", tags=["boards"])
@ -69,25 +71,26 @@ async def update_board(
raise HTTPException(status_code=500, detail="Failed to update board") raise HTTPException(status_code=500, detail="Failed to update board")
@boards_router.delete("/{board_id}", operation_id="delete_board") @boards_router.delete("/{board_id}", operation_id="delete_board", response_model=DeleteManyImagesResult)
async def delete_board( async def delete_board(
board_id: str = Path(description="The id of board to delete"), board_id: str = Path(description="The id of board to delete"),
include_images: Optional[bool] = Query( include_images: Optional[bool] = Query(
description="Permanently delete all images on the board", default=False description="Permanently delete all images on the board", default=False
), ),
) -> None: ) -> DeleteManyImagesResult:
"""Deletes a board""" """Deletes a board"""
try: try:
if include_images is True: if include_images is True:
ApiDependencies.invoker.services.images.delete_images_on_board( result = ApiDependencies.invoker.services.images.delete_images_on_board(
board_id=board_id board_id=board_id
) )
ApiDependencies.invoker.services.boards.delete(board_id=board_id) ApiDependencies.invoker.services.boards.delete(board_id=board_id)
else: else:
ApiDependencies.invoker.services.boards.delete(board_id=board_id) ApiDependencies.invoker.services.boards.delete(board_id=board_id)
result = DeleteManyImagesResult(deleted_images=[])
return result
except Exception as e: except Exception as e:
# TODO: Does this need any exception handling at all? raise HTTPException(status_code=500, detail="Failed to delete images on board")
pass
@boards_router.get( @boards_router.get(

View File

@ -1,20 +1,19 @@
import io import io
from typing import Optional from typing import Optional
from fastapi import Body, HTTPException, Path, Query, Request, Response, UploadFile
from fastapi.routing import APIRouter from fastapi import (Body, HTTPException, Path, Query, Request, Response,
UploadFile)
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.routing import APIRouter
from PIL import Image from PIL import Image
from invokeai.app.models.image import (
ImageCategory, from invokeai.app.models.image import (DeleteManyImagesResult, ImageCategory,
ResourceOrigin, ResourceOrigin)
)
from invokeai.app.services.image_record_storage import OffsetPaginatedResults from invokeai.app.services.image_record_storage import OffsetPaginatedResults
from invokeai.app.services.models.image_record import ( from invokeai.app.services.models.image_record import (GetImagesByNamesResult,
ImageDTO, ImageDTO,
ImageRecordChanges, ImageRecordChanges,
ImageUrlsDTO, ImageUrlsDTO)
)
from invokeai.app.services.item_storage import PaginatedResults
from ..dependencies import ApiDependencies from ..dependencies import ApiDependencies
@ -22,7 +21,7 @@ images_router = APIRouter(prefix="/v1/images", tags=["images"])
@images_router.post( @images_router.post(
"/", "/upload",
operation_id="upload_image", operation_id="upload_image",
responses={ responses={
201: {"description": "The image was uploaded successfully"}, 201: {"description": "The image was uploaded successfully"},
@ -103,14 +102,14 @@ async def update_image(
@images_router.get( @images_router.get(
"/{image_name}/metadata", "/{image_name}",
operation_id="get_image_metadata", operation_id="get_image",
response_model=ImageDTO, response_model=ImageDTO,
) )
async def get_image_metadata( async def get_image_dto(
image_name: str = Path(description="The name of image to get"), image_name: str = Path(description="The name of image to get"),
) -> ImageDTO: ) -> ImageDTO:
"""Gets an image's metadata""" """Gets an image's DTO"""
try: try:
return ApiDependencies.invoker.services.images.get_dto(image_name) return ApiDependencies.invoker.services.images.get_dto(image_name)
@ -119,8 +118,8 @@ async def get_image_metadata(
@images_router.get( @images_router.get(
"/{image_name}", "/{image_name}/full_size",
operation_id="get_image_full", operation_id="get_image_full_size",
response_class=Response, response_class=Response,
responses={ responses={
200: { 200: {
@ -130,7 +129,7 @@ async def get_image_metadata(
404: {"description": "Image not found"}, 404: {"description": "Image not found"},
}, },
) )
async def get_image_full( async def get_image_full_size(
image_name: str = Path(description="The name of full-resolution image file to get"), image_name: str = Path(description="The name of full-resolution image file to get"),
) -> FileResponse: ) -> FileResponse:
"""Gets a full-resolution image file""" """Gets a full-resolution image file"""
@ -208,10 +207,10 @@ async def get_image_urls(
@images_router.get( @images_router.get(
"/", "/",
operation_id="list_images_with_metadata", operation_id="get_many_images",
response_model=OffsetPaginatedResults[ImageDTO], response_model=OffsetPaginatedResults[ImageDTO],
) )
async def list_images_with_metadata( async def get_many_images(
image_origin: Optional[ResourceOrigin] = Query( image_origin: Optional[ResourceOrigin] = Query(
default=None, description="The origin of images to list" default=None, description="The origin of images to list"
), ),
@ -222,7 +221,8 @@ async def list_images_with_metadata(
default=None, description="Whether to list intermediate images" default=None, description="Whether to list intermediate images"
), ),
board_id: Optional[str] = Query( board_id: Optional[str] = Query(
default=None, description="The board id to filter by" default=None,
description="The board id to filter by, provide 'none' for images without a board",
), ),
offset: int = Query(default=0, description="The page offset"), offset: int = Query(default=0, description="The page offset"),
limit: int = Query(default=10, description="The number of images per page"), limit: int = Query(default=10, description="The number of images per page"),
@ -239,3 +239,36 @@ async def list_images_with_metadata(
) )
return image_dtos return image_dtos
@images_router.post(
"/",
operation_id="get_images_by_names",
response_model=GetImagesByNamesResult,
)
async def get_images_by_names(
image_names: list[str] = Body(description="The names of the images to get"),
) -> GetImagesByNamesResult:
"""Gets a list of images"""
result = ApiDependencies.invoker.services.images.get_images_by_names(
image_names
)
return result
@images_router.post(
"/delete",
operation_id="delete_many_images",
response_model=DeleteManyImagesResult,
)
async def delete_many_images(
image_names: list[str] = Body(description="The names of the images to delete"),
) -> DeleteManyImagesResult:
"""Deletes many images"""
try:
return ApiDependencies.invoker.services.images.delete_many(image_names)
except Exception as e:
raise HTTPException(status_code=500, detail="Failed to delete images")

View File

@ -1,5 +1,6 @@
from enum import Enum from enum import Enum
from typing import Optional, Tuple from typing import Optional, Tuple
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from invokeai.app.util.metaenum import MetaEnum from invokeai.app.util.metaenum import MetaEnum
@ -88,3 +89,41 @@ class ProgressImage(BaseModel):
width: int = Field(description="The effective width of the image in pixels") width: int = Field(description="The effective width of the image in pixels")
height: int = Field(description="The effective height of the image in pixels") height: int = Field(description="The effective height of the image in pixels")
dataURL: str = Field(description="The image data as a b64 data URL") dataURL: str = Field(description="The image data as a b64 data URL")
class DeleteManyImagesResult(BaseModel):
"""The result of a delete many image operation."""
deleted_images: list[str] = Field(
description="The names of the images that were successfully deleted"
)
class AddManyImagesToBoardResult(BaseModel):
"""The result of an add many images to board operation."""
board_id: str = Field(description="The id of the board the images were added to")
added_images: list[str] = Field(
description="The names of the images that were successfully added"
)
total: int = Field(description="The total number of images on the board")
class RemoveManyImagesFromBoardResult(BaseModel):
"""The result of a remove many images from their boards operation."""
removed_images: list[str] = Field(
description="The names of the images that were successfully removed from their boards"
)
class GetAllBoardImagesForBoardResult(BaseModel):
"""The result of a get all image names for board operation."""
board_id: str = Field(
description="The id of the board with which the images are associated"
)
image_names: list[str] = Field(
description="The names of the images that are associated with the board"
)

View File

@ -1,13 +1,12 @@
from abc import ABC, abstractmethod
import sqlite3 import sqlite3
import threading import threading
from abc import ABC, abstractmethod
from typing import Optional, cast from typing import Optional, cast
from invokeai.app.models.image import GetAllBoardImagesForBoardResult
from invokeai.app.services.image_record_storage import OffsetPaginatedResults from invokeai.app.services.image_record_storage import OffsetPaginatedResults
from invokeai.app.services.models.image_record import ( from invokeai.app.services.models.image_record import (
ImageRecord, ImageRecord, deserialize_image_record)
deserialize_image_record,
)
class BoardImageRecordStorageBase(ABC): class BoardImageRecordStorageBase(ABC):
@ -25,18 +24,17 @@ class BoardImageRecordStorageBase(ABC):
@abstractmethod @abstractmethod
def remove_image_from_board( def remove_image_from_board(
self, self,
board_id: str,
image_name: str, image_name: str,
) -> None: ) -> None:
"""Removes an image from a board.""" """Removes an image from a board."""
pass pass
@abstractmethod @abstractmethod
def get_images_for_board( def get_all_board_images_for_board(
self, self,
board_id: str, board_id: str,
) -> OffsetPaginatedResults[ImageRecord]: ) -> GetAllBoardImagesForBoardResult:
"""Gets images for a board.""" """Gets all image names for a board."""
pass pass
@abstractmethod @abstractmethod
@ -154,7 +152,6 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
def remove_image_from_board( def remove_image_from_board(
self, self,
board_id: str,
image_name: str, image_name: str,
) -> None: ) -> None:
try: try:
@ -162,9 +159,9 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
self._cursor.execute( self._cursor.execute(
"""--sql """--sql
DELETE FROM board_images DELETE FROM board_images
WHERE board_id = ? AND image_name = ?; WHERE image_name = ?;
""", """,
(board_id, image_name), (image_name,),
) )
self._conn.commit() self._conn.commit()
except sqlite3.Error as e: except sqlite3.Error as e:
@ -173,42 +170,32 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
finally: finally:
self._lock.release() self._lock.release()
def get_images_for_board( def get_all_board_images_for_board(
self, self,
board_id: str, board_id: str,
offset: int = 0, ) -> GetAllBoardImagesForBoardResult:
limit: int = 10,
) -> OffsetPaginatedResults[ImageRecord]:
# TODO: this isn't paginated yet?
try: try:
self._lock.acquire() self._lock.acquire()
self._cursor.execute( self._cursor.execute(
"""--sql """--sql
SELECT images.* SELECT image_name
FROM board_images FROM board_images
INNER JOIN images ON board_images.image_name = images.image_name WHERE board_id = ?
WHERE board_images.board_id = ? ORDER BY updated_at DESC;
ORDER BY board_images.updated_at DESC;
""", """,
(board_id,), (board_id,),
) )
result = cast(list[sqlite3.Row], self._cursor.fetchall())
images = list(map(lambda r: deserialize_image_record(dict(r)), result))
self._cursor.execute( result = cast(list[sqlite3.Row], self._cursor.fetchall())
"""--sql image_names = list(map(lambda r: r[0], result))
SELECT COUNT(*) FROM images WHERE 1=1;
"""
)
count = cast(int, self._cursor.fetchone()[0])
except sqlite3.Error as e: except sqlite3.Error as e:
self._conn.rollback() self._conn.rollback()
raise e raise e
finally: finally:
self._lock.release() self._lock.release()
return OffsetPaginatedResults( return GetAllBoardImagesForBoardResult(
items=images, offset=offset, limit=limit, total=count board_id=board_id, image_names=image_names
) )
def get_board_for_image( def get_board_for_image(

View File

@ -1,18 +1,19 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from logging import Logger from logging import Logger
from typing import List, Union, Optional from typing import List, Optional, Union
from invokeai.app.services.board_image_record_storage import BoardImageRecordStorageBase
from invokeai.app.services.board_record_storage import (
BoardRecord,
BoardRecordStorageBase,
)
from invokeai.app.services.image_record_storage import ( from invokeai.app.models.image import (AddManyImagesToBoardResult,
ImageRecordStorageBase, GetAllBoardImagesForBoardResult,
OffsetPaginatedResults, RemoveManyImagesFromBoardResult)
) from invokeai.app.services.board_image_record_storage import \
BoardImageRecordStorageBase
from invokeai.app.services.board_record_storage import (BoardRecord,
BoardRecordStorageBase)
from invokeai.app.services.image_record_storage import (ImageRecordStorageBase,
OffsetPaginatedResults)
from invokeai.app.services.models.board_record import BoardDTO from invokeai.app.services.models.board_record import BoardDTO
from invokeai.app.services.models.image_record import ImageDTO, image_record_to_dto from invokeai.app.services.models.image_record import (ImageDTO,
image_record_to_dto)
from invokeai.app.services.urls import UrlServiceBase from invokeai.app.services.urls import UrlServiceBase
@ -25,24 +26,40 @@ class BoardImagesServiceABC(ABC):
board_id: str, board_id: str,
image_name: str, image_name: str,
) -> None: ) -> None:
"""Adds an image to a board.""" """Adds an image to a board. If the image is on a different board, it is removed from that board."""
pass
@abstractmethod
def add_many_images_to_board(
self,
board_id: str,
image_names: list[str],
) -> AddManyImagesToBoardResult:
"""Adds many images to a board. If an image is on a different board, it is removed from that board."""
pass pass
@abstractmethod @abstractmethod
def remove_image_from_board( def remove_image_from_board(
self, self,
board_id: str,
image_name: str, image_name: str,
) -> None: ) -> None:
"""Removes an image from a board.""" """Removes an image from its board."""
pass pass
@abstractmethod @abstractmethod
def get_images_for_board( def remove_many_images_from_board(
self,
image_names: list[str],
) -> RemoveManyImagesFromBoardResult:
"""Removes many images from their board, if they had one."""
pass
@abstractmethod
def get_all_board_images_for_board(
self, self,
board_id: str, board_id: str,
) -> OffsetPaginatedResults[ImageDTO]: ) -> GetAllBoardImagesForBoardResult:
"""Gets images for a board.""" """Gets all image names for a board."""
pass pass
@abstractmethod @abstractmethod
@ -91,37 +108,59 @@ class BoardImagesService(BoardImagesServiceABC):
) -> None: ) -> None:
self._services.board_image_records.add_image_to_board(board_id, image_name) self._services.board_image_records.add_image_to_board(board_id, image_name)
def add_many_images_to_board(
self,
board_id: str,
image_names: list[str],
) -> AddManyImagesToBoardResult:
added_images: list[str] = []
for image_name in image_names:
try:
self._services.board_image_records.add_image_to_board(
board_id, image_name
)
added_images.append(image_name)
except Exception as e:
self._services.logger.exception(e)
total = self._services.board_image_records.get_image_count_for_board(board_id)
return AddManyImagesToBoardResult(
board_id=board_id, added_images=added_images, total=total
)
def remove_image_from_board( def remove_image_from_board(
self, self,
board_id: str,
image_name: str, image_name: str,
) -> None: ) -> None:
self._services.board_image_records.remove_image_from_board(board_id, image_name) self._services.board_image_records.remove_image_from_board(image_name)
def get_images_for_board( def remove_many_images_from_board(
self,
image_names: list[str],
) -> RemoveManyImagesFromBoardResult:
removed_images: list[str] = []
for image_name in image_names:
try:
self._services.board_image_records.remove_image_from_board(image_name)
removed_images.append(image_name)
except Exception as e:
self._services.logger.exception(e)
return RemoveManyImagesFromBoardResult(
removed_images=removed_images,
)
def get_all_board_images_for_board(
self, self,
board_id: str, board_id: str,
) -> OffsetPaginatedResults[ImageDTO]: ) -> GetAllBoardImagesForBoardResult:
image_records = self._services.board_image_records.get_images_for_board( result = self._services.board_image_records.get_all_board_images_for_board(
board_id board_id
) )
image_dtos = list( return result
map(
lambda r: image_record_to_dto(
r,
self._services.urls.get_image_url(r.image_name),
self._services.urls.get_image_url(r.image_name, True),
board_id,
),
image_records.items,
)
)
return OffsetPaginatedResults[ImageDTO](
items=image_dtos,
offset=image_records.offset,
limit=image_records.limit,
total=image_records.total,
)
def get_board_for_image( def get_board_for_image(
self, self,
@ -136,7 +175,7 @@ def board_record_to_dto(
) -> BoardDTO: ) -> BoardDTO:
"""Converts a board record to a board DTO.""" """Converts a board record to a board DTO."""
return BoardDTO( return BoardDTO(
**board_record.dict(exclude={'cover_image_name'}), **board_record.dict(exclude={"cover_image_name"}),
cover_image_name=cover_image_name, cover_image_name=cover_image_name,
image_count=image_count, image_count=image_count,
) )

View File

@ -80,6 +80,11 @@ class ImageRecordStorageBase(ABC):
"""Gets a page of image records.""" """Gets a page of image records."""
pass pass
@abstractmethod
def get_by_names(self, image_names: list[str]) -> list[ImageRecord]:
"""Gets a list of image records by name."""
pass
# TODO: The database has a nullable `deleted_at` column, currently unused. # TODO: The database has a nullable `deleted_at` column, currently unused.
# Should we implement soft deletes? Would need coordination with ImageFileStorage. # Should we implement soft deletes? Would need coordination with ImageFileStorage.
@abstractmethod @abstractmethod
@ -329,11 +334,15 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
query_params.append(is_intermediate) query_params.append(is_intermediate)
if board_id is not None: if board_id is not None:
query_conditions += """--sql if board_id == "none":
AND board_images.board_id = ? query_conditions += """--sql
""" AND board_images.board_id IS NULL
"""
query_params.append(board_id) else:
query_conditions += """--sql
AND board_images.board_id = ?
"""
query_params.append(board_id)
query_pagination = """--sql query_pagination = """--sql
ORDER BY images.created_at DESC LIMIT ? OFFSET ? ORDER BY images.created_at DESC LIMIT ? OFFSET ?
@ -365,6 +374,30 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
items=images, offset=offset, limit=limit, total=count items=images, offset=offset, limit=limit, total=count
) )
def get_by_names(self, image_names: list[str]) -> list[ImageRecord]:
try:
placeholders = ",".join("?" for _ in image_names)
self._lock.acquire()
# Construct the SQLite query with the placeholders
query = f"""--sql
SELECT * FROM images
WHERE image_name IN ({placeholders})
"""
# Execute the query with the list of IDs as parameters
self._cursor.execute(query, image_names)
result = cast(list[sqlite3.Row], self._cursor.fetchall())
images = list(map(lambda r: deserialize_image_record(dict(r)), result))
return images
except sqlite3.Error as e:
self._conn.rollback()
raise e
finally:
self._lock.release()
def delete(self, image_name: str) -> None: def delete(self, image_name: str) -> None:
try: try:
self._lock.acquire() self._lock.acquire()
@ -465,9 +498,7 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
finally: finally:
self._lock.release() self._lock.release()
def get_most_recent_image_for_board( def get_most_recent_image_for_board(self, board_id: str) -> Optional[ImageRecord]:
self, board_id: str
) -> Optional[ImageRecord]:
try: try:
self._lock.acquire() self._lock.acquire()
self._cursor.execute( self._cursor.execute(

View File

@ -1,37 +1,27 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from logging import Logger from logging import Logger
from typing import Optional, TYPE_CHECKING, Union from typing import TYPE_CHECKING, Optional
from PIL.Image import Image as PILImageType from PIL.Image import Image as PILImageType
from invokeai.app.models.image import ( from invokeai.app.models.image import (DeleteManyImagesResult, ImageCategory,
ImageCategory, InvalidImageCategoryException,
ResourceOrigin, InvalidOriginException, ResourceOrigin)
InvalidImageCategoryException,
InvalidOriginException,
)
from invokeai.app.models.metadata import ImageMetadata from invokeai.app.models.metadata import ImageMetadata
from invokeai.app.services.board_image_record_storage import BoardImageRecordStorageBase from invokeai.app.services.board_image_record_storage import \
from invokeai.app.services.image_record_storage import ( BoardImageRecordStorageBase
ImageRecordDeleteException,
ImageRecordNotFoundException,
ImageRecordSaveException,
ImageRecordStorageBase,
OffsetPaginatedResults,
)
from invokeai.app.services.models.image_record import (
ImageRecord,
ImageDTO,
ImageRecordChanges,
image_record_to_dto,
)
from invokeai.app.services.image_file_storage import ( from invokeai.app.services.image_file_storage import (
ImageFileDeleteException, ImageFileDeleteException, ImageFileNotFoundException,
ImageFileNotFoundException, ImageFileSaveException, ImageFileStorageBase)
ImageFileSaveException, from invokeai.app.services.image_record_storage import (
ImageFileStorageBase, ImageRecordDeleteException, ImageRecordNotFoundException,
) ImageRecordSaveException, ImageRecordStorageBase, OffsetPaginatedResults)
from invokeai.app.services.item_storage import ItemStorageABC, PaginatedResults from invokeai.app.services.item_storage import ItemStorageABC
from invokeai.app.services.metadata import MetadataServiceBase from invokeai.app.services.metadata import MetadataServiceBase
from invokeai.app.services.models.image_record import (GetImagesByNamesResult,
ImageDTO, ImageRecord,
ImageRecordChanges,
image_record_to_dto)
from invokeai.app.services.resource_name import NameServiceBase from invokeai.app.services.resource_name import NameServiceBase
from invokeai.app.services.urls import UrlServiceBase from invokeai.app.services.urls import UrlServiceBase
@ -107,13 +97,23 @@ class ImageServiceABC(ABC):
"""Gets a paginated list of image DTOs.""" """Gets a paginated list of image DTOs."""
pass pass
@abstractmethod
def get_images_by_names(self, image_names: list[str]) -> GetImagesByNamesResult:
"""Gets image DTOs by list of names."""
pass
@abstractmethod @abstractmethod
def delete(self, image_name: str): def delete(self, image_name: str):
"""Deletes an image.""" """Deletes an image."""
pass pass
@abstractmethod @abstractmethod
def delete_images_on_board(self, board_id: str): def delete_many(self, image_names: list[str]) -> DeleteManyImagesResult:
"""Deletes many images."""
pass
@abstractmethod
def delete_images_on_board(self, board_id: str) -> DeleteManyImagesResult:
"""Deletes all images on a board.""" """Deletes all images on a board."""
pass pass
@ -332,6 +332,28 @@ class ImageService(ImageServiceABC):
self._services.logger.error("Problem getting paginated image DTOs") self._services.logger.error("Problem getting paginated image DTOs")
raise e raise e
def get_images_by_names(self, image_names: list[str]) -> GetImagesByNamesResult:
try:
image_records = self._services.image_records.get_by_names(image_names)
image_dtos = list(
map(
lambda r: image_record_to_dto(
r,
self._services.urls.get_image_url(r.image_name),
self._services.urls.get_image_url(r.image_name, True),
self._services.board_image_records.get_board_for_image(
r.image_name
),
),
image_records,
)
)
return GetImagesByNamesResult(image_dtos=image_dtos)
except Exception as e:
self._services.logger.error("Problem getting image DTOs from names")
raise e
def delete(self, image_name: str): def delete(self, image_name: str):
try: try:
self._services.image_files.delete(image_name) self._services.image_files.delete(image_name)
@ -346,18 +368,36 @@ class ImageService(ImageServiceABC):
self._services.logger.error("Problem deleting image record and file") self._services.logger.error("Problem deleting image record and file")
raise e raise e
def delete_images_on_board(self, board_id: str): def delete_many(self, image_names: list[str]) -> DeleteManyImagesResult:
deleted_images: list[str] = []
for image_name in image_names:
try:
self._services.image_files.delete(image_name)
self._services.image_records.delete(image_name)
deleted_images.append(image_name)
except ImageRecordDeleteException:
self._services.logger.error(f"Failed to delete image record")
deleted_images.append(image_name)
except ImageFileDeleteException:
self._services.logger.error(f"Failed to delete image file")
deleted_images.append(image_name)
except Exception as e:
self._services.logger.error("Problem deleting image record and file")
deleted_images.append(image_name)
return DeleteManyImagesResult(deleted_images=deleted_images)
def delete_images_on_board(self, board_id: str) -> DeleteManyImagesResult:
try: try:
images = self._services.board_image_records.get_images_for_board(board_id) board_images = (
image_name_list = list( self._services.board_image_records.get_all_board_images_for_board(
map( board_id
lambda r: r.image_name,
images.items,
) )
) )
image_name_list = board_images.image_names
for image_name in image_name_list: for image_name in image_name_list:
self._services.image_files.delete(image_name) self._services.image_files.delete(image_name)
self._services.image_records.delete_many(image_name_list) self._services.image_records.delete_many(image_name_list)
return DeleteManyImagesResult(deleted_images=board_images.image_names)
except ImageRecordDeleteException: except ImageRecordDeleteException:
self._services.logger.error(f"Failed to delete image records") self._services.logger.error(f"Failed to delete image records")
raise raise

View File

@ -1,6 +1,8 @@
import datetime import datetime
from typing import Optional, Union from typing import Optional, Union
from pydantic import BaseModel, Extra, Field, StrictBool, StrictStr from pydantic import BaseModel, Extra, Field, StrictBool, StrictStr
from invokeai.app.models.image import ImageCategory, ResourceOrigin from invokeai.app.models.image import ImageCategory, ResourceOrigin
from invokeai.app.models.metadata import ImageMetadata from invokeai.app.models.metadata import ImageMetadata
from invokeai.app.util.misc import get_iso_timestamp from invokeai.app.util.misc import get_iso_timestamp
@ -95,8 +97,19 @@ class ImageDTO(ImageRecord, ImageUrlsDTO):
pass pass
class GetImagesByNamesResult(BaseModel):
"""The result of a get all image names for board operation."""
image_dtos: list[ImageDTO] = Field(
description="The names of the images that are associated with the board"
)
def image_record_to_dto( def image_record_to_dto(
image_record: ImageRecord, image_url: str, thumbnail_url: str, board_id: Optional[str] image_record: ImageRecord,
image_url: str,
thumbnail_url: str,
board_id: Optional[str],
) -> ImageDTO: ) -> ImageDTO:
"""Converts an image record to an image DTO.""" """Converts an image record to an image DTO."""
return ImageDTO( return ImageDTO(

View File

@ -22,4 +22,4 @@ class LocalUrlService(UrlServiceBase):
if thumbnail: if thumbnail:
return f"{self._base_url}/images/{image_basename}/thumbnail" return f"{self._base_url}/images/{image_basename}/thumbnail"
return f"{self._base_url}/images/{image_basename}" return f"{self._base_url}/images/{image_basename}/full_size"

View File

@ -128,13 +128,13 @@
"@types/react-redux": "^7.1.25", "@types/react-redux": "^7.1.25",
"@types/react-transition-group": "^4.4.6", "@types/react-transition-group": "^4.4.6",
"@types/uuid": "^9.0.2", "@types/uuid": "^9.0.2",
"@typescript-eslint/eslint-plugin": "^5.60.0", "@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^5.60.0", "@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react-swc": "^3.3.2", "@vitejs/plugin-react-swc": "^3.3.2",
"axios": "^1.4.0", "axios": "^1.4.0",
"babel-plugin-transform-imports": "^2.0.0", "babel-plugin-transform-imports": "^2.0.0",
"concurrently": "^8.2.0", "concurrently": "^8.2.0",
"eslint": "^8.43.0", "eslint": "^8.44.0",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.2", "eslint-plugin-react": "^7.32.2",
@ -151,6 +151,8 @@
"rollup-plugin-visualizer": "^5.9.2", "rollup-plugin-visualizer": "^5.9.2",
"terser": "^5.18.1", "terser": "^5.18.1",
"ts-toolbelt": "^9.6.0", "ts-toolbelt": "^9.6.0",
"typescript": "^5.1.6",
"typescript-eslint": "^0.0.1-alpha.0",
"vite": "^4.3.9", "vite": "^4.3.9",
"vite-plugin-css-injected-by-js": "^3.1.1", "vite-plugin-css-injected-by-js": "^3.1.1",
"vite-plugin-dts": "^2.3.0", "vite-plugin-dts": "^2.3.0",

View File

@ -118,7 +118,7 @@
"pinGallery": "Pin Gallery", "pinGallery": "Pin Gallery",
"allImagesLoaded": "All Images Loaded", "allImagesLoaded": "All Images Loaded",
"loadMore": "Load More", "loadMore": "Load More",
"noImagesInGallery": "No Images In Gallery", "noImagesInGallery": "No Images to Display",
"deleteImage": "Delete Image", "deleteImage": "Delete Image",
"deleteImageBin": "Deleted images will be sent to your operating system's Bin.", "deleteImageBin": "Deleted images will be sent to your operating system's Bin.",
"deleteImagePermanent": "Deleted images cannot be restored.", "deleteImagePermanent": "Deleted images cannot be restored.",

View File

@ -82,7 +82,7 @@ const DragPreview = (props: OverlayDragImageProps) => {
); );
} }
if (props.dragData.payloadType === 'BATCH_SELECTION') { if (props.dragData.payloadType === 'IMAGE_NAMES') {
return ( return (
<Flex <Flex
sx={{ sx={{
@ -95,26 +95,7 @@ const DragPreview = (props: OverlayDragImageProps) => {
...STYLES, ...STYLES,
}} }}
> >
<Heading>{batchSelectionCount}</Heading> <Heading>{props.dragData.payload.image_names.length}</Heading>
<Heading size="sm">Images</Heading>
</Flex>
);
}
if (props.dragData.payloadType === 'GALLERY_SELECTION') {
return (
<Flex
sx={{
cursor: 'none',
userSelect: 'none',
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
flexDir: 'column',
...STYLES,
}}
>
<Heading>{gallerySelectionCount}</Heading>
<Heading size="sm">Images</Heading> <Heading size="sm">Images</Heading>
</Flex> </Flex>
); );

View File

@ -6,18 +6,18 @@ import {
useSensor, useSensor,
useSensors, useSensors,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { snapCenterToCursor } from '@dnd-kit/modifiers';
import { dndDropped } from 'app/store/middleware/listenerMiddleware/listeners/imageDropped';
import { useAppDispatch } from 'app/store/storeHooks';
import { AnimatePresence, motion } from 'framer-motion';
import { PropsWithChildren, memo, useCallback, useState } from 'react'; import { PropsWithChildren, memo, useCallback, useState } from 'react';
import DragPreview from './DragPreview'; import DragPreview from './DragPreview';
import { snapCenterToCursor } from '@dnd-kit/modifiers';
import { AnimatePresence, motion } from 'framer-motion';
import { import {
DndContext, DndContext,
DragEndEvent, DragEndEvent,
DragStartEvent, DragStartEvent,
TypesafeDraggableData, TypesafeDraggableData,
} from './typesafeDnd'; } from './typesafeDnd';
import { useAppDispatch } from 'app/store/storeHooks';
import { imageDropped } from 'app/store/middleware/listenerMiddleware/listeners/imageDropped';
type ImageDndContextProps = PropsWithChildren; type ImageDndContextProps = PropsWithChildren;
@ -42,18 +42,18 @@ const ImageDndContext = (props: ImageDndContextProps) => {
if (!activeData || !overData) { if (!activeData || !overData) {
return; return;
} }
dispatch(imageDropped({ overData, activeData })); dispatch(dndDropped({ overData, activeData }));
setActiveDragData(null); setActiveDragData(null);
}, },
[dispatch] [dispatch]
); );
const mouseSensor = useSensor(MouseSensor, { const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { delay: 150, tolerance: 5 }, activationConstraint: { distance: 10 },
}); });
const touchSensor = useSensor(TouchSensor, { const touchSensor = useSensor(TouchSensor, {
activationConstraint: { delay: 150, tolerance: 5 }, activationConstraint: { distance: 10 },
}); });
// TODO: Use KeyboardSensor - needs composition of multiple collisionDetection algos // TODO: Use KeyboardSensor - needs composition of multiple collisionDetection algos

View File

@ -77,18 +77,14 @@ export type ImageDraggableData = BaseDragData & {
payload: { imageDTO: ImageDTO }; payload: { imageDTO: ImageDTO };
}; };
export type GallerySelectionDraggableData = BaseDragData & { export type ImageNamesDraggableData = BaseDragData & {
payloadType: 'GALLERY_SELECTION'; payloadType: 'IMAGE_NAMES';
}; payload: { image_names: string[] };
export type BatchSelectionDraggableData = BaseDragData & {
payloadType: 'BATCH_SELECTION';
}; };
export type TypesafeDraggableData = export type TypesafeDraggableData =
| ImageDraggableData | ImageDraggableData
| GallerySelectionDraggableData | ImageNamesDraggableData;
| BatchSelectionDraggableData;
interface UseDroppableTypesafeArguments interface UseDroppableTypesafeArguments
extends Omit<UseDroppableArguments, 'data'> { extends Omit<UseDroppableArguments, 'data'> {
@ -159,13 +155,11 @@ export const isValidDrop = (
case 'SET_NODES_IMAGE': case 'SET_NODES_IMAGE':
return payloadType === 'IMAGE_DTO'; return payloadType === 'IMAGE_DTO';
case 'SET_MULTI_NODES_IMAGE': case 'SET_MULTI_NODES_IMAGE':
return payloadType === 'IMAGE_DTO' || 'GALLERY_SELECTION'; return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
case 'ADD_TO_BATCH': case 'ADD_TO_BATCH':
return payloadType === 'IMAGE_DTO' || 'GALLERY_SELECTION'; return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
case 'MOVE_BOARD': case 'MOVE_BOARD':
return ( return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
payloadType === 'IMAGE_DTO' || 'GALLERY_SELECTION' || 'BATCH_SELECTION'
);
default: default:
return false; return false;
} }

View File

@ -1,7 +1,7 @@
import { useDisclosure } from '@chakra-ui/react'; import { useDisclosure } from '@chakra-ui/react';
import { PropsWithChildren, createContext, useCallback, useState } from 'react'; import { PropsWithChildren, createContext, useCallback, useState } from 'react';
import { useAddBoardImageMutation } from 'services/api/endpoints/boardImages';
import { ImageDTO } from 'services/api/types'; import { ImageDTO } from 'services/api/types';
import { useAddImageToBoardMutation } from 'services/api/endpoints/boardImages';
export type ImageUsage = { export type ImageUsage = {
isInitialImage: boolean; isInitialImage: boolean;
@ -41,7 +41,7 @@ export const AddImageToBoardContextProvider = (props: Props) => {
const [imageToMove, setImageToMove] = useState<ImageDTO>(); const [imageToMove, setImageToMove] = useState<ImageDTO>();
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const [addImageToBoard, result] = useAddImageToBoardMutation(); const [addImageToBoard, result] = useAddBoardImageMutation();
// Clean up after deleting or dismissing the modal // Clean up after deleting or dismissing the modal
const closeAndClearImageToDelete = useCallback(() => { const closeAndClearImageToDelete = useCallback(() => {

View File

@ -1,3 +1,4 @@
import { initialBatchState } from 'features/batch/store/batchSlice';
import { initialCanvasState } from 'features/canvas/store/canvasSlice'; import { initialCanvasState } from 'features/canvas/store/canvasSlice';
import { initialControlNetState } from 'features/controlNet/store/controlNetSlice'; import { initialControlNetState } from 'features/controlNet/store/controlNetSlice';
import { initialGalleryState } from 'features/gallery/store/gallerySlice'; import { initialGalleryState } from 'features/gallery/store/gallerySlice';
@ -17,6 +18,7 @@ const initialStates: {
} = { } = {
canvas: initialCanvasState, canvas: initialCanvasState,
gallery: initialGalleryState, gallery: initialGalleryState,
batch: initialBatchState,
generation: initialGenerationState, generation: initialGenerationState,
lightbox: initialLightboxState, lightbox: initialLightboxState,
nodes: initialNodesState, nodes: initialNodesState,

View File

@ -1,4 +1,8 @@
/**
* This is a list of actions that should be excluded in the Redux DevTools.
*/
export const actionsDenylist = [ export const actionsDenylist = [
// very spammy canvas actions
'canvas/setCursorPosition', 'canvas/setCursorPosition',
'canvas/setStageCoordinates', 'canvas/setStageCoordinates',
'canvas/setStageScale', 'canvas/setStageScale',
@ -7,7 +11,11 @@ export const actionsDenylist = [
'canvas/setBoundingBoxDimensions', 'canvas/setBoundingBoxDimensions',
'canvas/setIsDrawing', 'canvas/setIsDrawing',
'canvas/addPointToCurrentLine', 'canvas/addPointToCurrentLine',
// bazillions during generation
'socket/socketGeneratorProgress', 'socket/socketGeneratorProgress',
'socket/appSocketGeneratorProgress', 'socket/appSocketGeneratorProgress',
// every time user presses shift
'hotkeys/shiftKeyPressed', 'hotkeys/shiftKeyPressed',
// this happens after every state change
'@@REMEMBER_PERSISTED',
]; ];

View File

@ -7,6 +7,8 @@ import {
} from '@reduxjs/toolkit'; } from '@reduxjs/toolkit';
import type { AppDispatch, RootState } from '../../store'; import type { AppDispatch, RootState } from '../../store';
import { addBoardApiListeners } from './listeners/addBoardApiListeners';
import { addAddBoardToBatchListener } from './listeners/addBoardToBatch';
import { addCommitStagingAreaImageListener } from './listeners/addCommitStagingAreaImageListener'; import { addCommitStagingAreaImageListener } from './listeners/addCommitStagingAreaImageListener';
import { addAppStartedListener } from './listeners/appStarted'; import { addAppStartedListener } from './listeners/appStarted';
import { addBoardIdSelectedListener } from './listeners/boardIdSelected'; import { addBoardIdSelectedListener } from './listeners/boardIdSelected';
@ -18,9 +20,9 @@ import { addCanvasSavedToGalleryListener } from './listeners/canvasSavedToGaller
import { addControlNetAutoProcessListener } from './listeners/controlNetAutoProcess'; import { addControlNetAutoProcessListener } from './listeners/controlNetAutoProcess';
import { addControlNetImageProcessedListener } from './listeners/controlNetImageProcessed'; import { addControlNetImageProcessedListener } from './listeners/controlNetImageProcessed';
import { import {
addImageAddedToBoardFulfilledListener, addImageDTOReceivedFulfilledListener,
addImageAddedToBoardRejectedListener, addImageDTOReceivedRejectedListener,
} from './listeners/imageAddedToBoard'; } from './listeners/imageDTOReceived';
import { import {
addImageDeletedFulfilledListener, addImageDeletedFulfilledListener,
addImageDeletedPendingListener, addImageDeletedPendingListener,
@ -28,14 +30,6 @@ import {
addRequestedImageDeletionListener, addRequestedImageDeletionListener,
} from './listeners/imageDeleted'; } from './listeners/imageDeleted';
import { addImageDroppedListener } from './listeners/imageDropped'; import { addImageDroppedListener } from './listeners/imageDropped';
import {
addImageMetadataReceivedFulfilledListener,
addImageMetadataReceivedRejectedListener,
} from './listeners/imageMetadataReceived';
import {
addImageRemovedFromBoardFulfilledListener,
addImageRemovedFromBoardRejectedListener,
} from './listeners/imageRemovedFromBoard';
import { addImageToDeleteSelectedListener } from './listeners/imageToDeleteSelected'; import { addImageToDeleteSelectedListener } from './listeners/imageToDeleteSelected';
import { import {
addImageUpdatedFulfilledListener, addImageUpdatedFulfilledListener,
@ -45,18 +39,11 @@ import {
addImageUploadedFulfilledListener, addImageUploadedFulfilledListener,
addImageUploadedRejectedListener, addImageUploadedRejectedListener,
} from './listeners/imageUploaded'; } from './listeners/imageUploaded';
import { import { addImagesLoadedListener } from './listeners/imagesLoaded';
addImageUrlsReceivedFulfilledListener,
addImageUrlsReceivedRejectedListener,
} from './listeners/imageUrlsReceived';
import { addInitialImageSelectedListener } from './listeners/initialImageSelected'; import { addInitialImageSelectedListener } from './listeners/initialImageSelected';
import { addModelSelectedListener } from './listeners/modelSelected'; import { addModelSelectedListener } from './listeners/modelSelected';
import { addReceivedOpenAPISchemaListener } from './listeners/receivedOpenAPISchema'; import { addReceivedOpenAPISchemaListener } from './listeners/receivedOpenAPISchema';
import { import { addReceivedPageOfImagesListener } from './listeners/receivedPageOfImages';
addReceivedPageOfImagesFulfilledListener,
addReceivedPageOfImagesRejectedListener,
} from './listeners/receivedPageOfImages';
import { addSelectionAddedToBatchListener } from './listeners/selectionAddedToBatch';
import { import {
addSessionCanceledFulfilledListener, addSessionCanceledFulfilledListener,
addSessionCanceledPendingListener, addSessionCanceledPendingListener,
@ -132,12 +119,8 @@ addRequestedBoardImageDeletionListener();
addImageToDeleteSelectedListener(); addImageToDeleteSelectedListener();
// Image metadata // Image metadata
addImageMetadataReceivedFulfilledListener(); addImageDTOReceivedFulfilledListener();
addImageMetadataReceivedRejectedListener(); addImageDTOReceivedRejectedListener();
// Image URLs
addImageUrlsReceivedFulfilledListener();
addImageUrlsReceivedRejectedListener();
// User Invoked // User Invoked
addUserInvokedCanvasListener(); addUserInvokedCanvasListener();
@ -193,8 +176,8 @@ addSessionCanceledFulfilledListener();
addSessionCanceledRejectedListener(); addSessionCanceledRejectedListener();
// Fetching images // Fetching images
addReceivedPageOfImagesFulfilledListener(); addReceivedPageOfImagesListener();
addReceivedPageOfImagesRejectedListener(); addImagesLoadedListener();
// ControlNet // ControlNet
addControlNetImageProcessedListener(); addControlNetImageProcessedListener();
@ -204,17 +187,15 @@ addControlNetAutoProcessListener();
// addUpdateImageUrlsOnConnectListener(); // addUpdateImageUrlsOnConnectListener();
// Boards // Boards
addImageAddedToBoardFulfilledListener(); addBoardApiListeners();
addImageAddedToBoardRejectedListener();
addImageRemovedFromBoardFulfilledListener();
addImageRemovedFromBoardRejectedListener();
addBoardIdSelectedListener(); addBoardIdSelectedListener();
// Node schemas // Node schemas
addReceivedOpenAPISchemaListener(); addReceivedOpenAPISchemaListener();
// Batches // Batches
addSelectionAddedToBatchListener(); // addSelectionAddedToBatchListener();
addAddBoardToBatchListener();
// DND // DND
addImageDroppedListener(); addImageDroppedListener();

View File

@ -0,0 +1,105 @@
import { log } from 'app/logging/useLogger';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'boards' });
export const addBoardApiListeners = () => {
// add image to board - fulfilled
startAppListening({
matcher: boardImagesApi.endpoints.addBoardImage.matchFulfilled,
effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_name } },
'Image added to board'
);
},
});
// add image to board - rejected
startAppListening({
matcher: boardImagesApi.endpoints.addBoardImage.matchRejected,
effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_name } },
'Problem adding image to board'
);
},
});
// remove image from board - fulfilled
startAppListening({
matcher: boardImagesApi.endpoints.deleteBoardImage.matchFulfilled,
effect: (action, { getState, dispatch }) => {
const { image_name } = action.meta.arg.originalArgs;
moduleLog.debug({ data: { image_name } }, 'Image removed from board');
},
});
// remove image from board - rejected
startAppListening({
matcher: boardImagesApi.endpoints.deleteBoardImage.matchRejected,
effect: (action, { getState, dispatch }) => {
const image_name = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { image_name } },
'Problem removing image from board'
);
},
});
// many images added to board - fulfilled
startAppListening({
matcher: boardImagesApi.endpoints.addManyBoardImages.matchFulfilled,
effect: (action, { getState, dispatch }) => {
const { board_id, image_names } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_names } },
'Images added to board'
);
},
});
// many images added to board - rejected
startAppListening({
matcher: boardImagesApi.endpoints.addManyBoardImages.matchRejected,
effect: (action, { getState, dispatch }) => {
const { board_id, image_names } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_names } },
'Problem adding many images to board'
);
},
});
// remove many images from board - fulfilled
startAppListening({
matcher: boardImagesApi.endpoints.deleteManyBoardImages.matchFulfilled,
effect: (action, { getState, dispatch }) => {
const { image_names } = action.meta.arg.originalArgs;
moduleLog.debug({ data: { image_names } }, 'Images removed from board');
},
});
// remove many images from board - rejected
startAppListening({
matcher: boardImagesApi.endpoints.deleteManyBoardImages.matchRejected,
effect: (action, { getState, dispatch }) => {
const image_names = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { image_names } },
'Problem removing many images from board'
);
},
});
};

View File

@ -0,0 +1,31 @@
import { createAction } from '@reduxjs/toolkit';
import { log } from 'app/logging/useLogger';
import { imagesAddedToBatch } from 'features/batch/store/batchSlice';
import { boardImageNamesReceived } from 'services/api/thunks/boardImages';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'batch' });
export const boardAddedToBatch = createAction<{ board_id: string }>(
'batch/boardAddedToBatch'
);
export const addAddBoardToBatchListener = () => {
startAppListening({
actionCreator: boardAddedToBatch,
effect: async (action, { dispatch, getState, take }) => {
const { board_id } = action.payload;
const { requestId } = dispatch(boardImageNamesReceived({ board_id }));
const [{ payload }] = await take(
(
action
): action is ReturnType<typeof boardImageNamesReceived.fulfilled> =>
action.meta.requestId === requestId
);
dispatch(imagesAddedToBatch(payload.image_names));
},
});
};

View File

@ -1,9 +1,4 @@
import { createAction } from '@reduxjs/toolkit'; import { createAction } from '@reduxjs/toolkit';
import {
INITIAL_IMAGE_LIMIT,
isLoadingChanged,
} from 'features/gallery/store/gallerySlice';
import { receivedPageOfImages } from 'services/api/thunks/image';
import { startAppListening } from '..'; import { startAppListening } from '..';
export const appStarted = createAction('app/appStarted'); export const appStarted = createAction('app/appStarted');
@ -15,29 +10,27 @@ export const addAppStartedListener = () => {
action, action,
{ getState, dispatch, unsubscribe, cancelActiveListeners } { getState, dispatch, unsubscribe, cancelActiveListeners }
) => { ) => {
cancelActiveListeners(); // cancelActiveListeners();
unsubscribe(); // unsubscribe();
// fill up the gallery tab with images // // fill up the gallery tab with images
await dispatch( // await dispatch(
receivedPageOfImages({ // receivedPageOfImages({
categories: ['general'], // categories: ['general'],
is_intermediate: false, // is_intermediate: false,
offset: 0, // offset: 0,
limit: INITIAL_IMAGE_LIMIT, // // limit: INITIAL_IMAGE_LIMIT,
}) // })
); // );
// // fill up the assets tab with images
// fill up the assets tab with images // await dispatch(
await dispatch( // receivedPageOfImages({
receivedPageOfImages({ // categories: ['control', 'mask', 'user', 'other'],
categories: ['control', 'mask', 'user', 'other'], // is_intermediate: false,
is_intermediate: false, // offset: 0,
offset: 0, // // limit: INITIAL_IMAGE_LIMIT,
limit: INITIAL_IMAGE_LIMIT, // })
}) // );
); // dispatch(isLoadingChanged(false));
dispatch(isLoadingChanged(false));
}, },
}); });
}; };

View File

@ -1,15 +1,6 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { startAppListening } from '..'; import { startAppListening } from '..';
import {
imageSelected,
selectImagesAll,
boardIdSelected,
} from 'features/gallery/store/gallerySlice';
import {
IMAGES_PER_PAGE,
receivedPageOfImages,
} from 'services/api/thunks/image';
import { boardsApi } from 'services/api/endpoints/boards';
const moduleLog = log.child({ namespace: 'boards' }); const moduleLog = log.child({ namespace: 'boards' });
@ -17,49 +8,40 @@ export const addBoardIdSelectedListener = () => {
startAppListening({ startAppListening({
actionCreator: boardIdSelected, actionCreator: boardIdSelected,
effect: (action, { getState, dispatch }) => { effect: (action, { getState, dispatch }) => {
const board_id = action.payload; // const board_id = action.payload;
// // we need to check if we need to fetch more images
// we need to check if we need to fetch more images // const state = getState();
// const allImages = selectImagesAll(state);
const state = getState(); // if (!board_id) {
const allImages = selectImagesAll(state); // // a board was unselected
// dispatch(imageSelected(allImages[0]?.image_name));
if (!board_id) { // return;
// a board was unselected // }
dispatch(imageSelected(allImages[0]?.image_name)); // const { categories } = state.gallery;
return; // const filteredImages = allImages.filter((i) => {
} // const isInCategory = categories.includes(i.image_category);
// const isInSelectedBoard = board_id ? i.board_id === board_id : true;
const { categories } = state.gallery; // return isInCategory && isInSelectedBoard;
// });
const filteredImages = allImages.filter((i) => { // // get the board from the cache
const isInCategory = categories.includes(i.image_category); // const { data: boards } =
const isInSelectedBoard = board_id ? i.board_id === board_id : true; // boardsApi.endpoints.listAllBoards.select()(state);
return isInCategory && isInSelectedBoard; // const board = boards?.find((b) => b.board_id === board_id);
}); // if (!board) {
// // can't find the board in cache...
// get the board from the cache // dispatch(imageSelected(allImages[0]?.image_name));
const { data: boards } = // return;
boardsApi.endpoints.listAllBoards.select()(state); // }
const board = boards?.find((b) => b.board_id === board_id); // dispatch(imageSelected(board.cover_image_name ?? null));
// // if we haven't loaded one full page of images from this board, load more
if (!board) { // if (
// can't find the board in cache... // filteredImages.length < board.image_count &&
dispatch(imageSelected(allImages[0]?.image_name)); // filteredImages.length < IMAGES_PER_PAGE
return; // ) {
} // dispatch(
// receivedPageOfImages({ categories, board_id, is_intermediate: false })
dispatch(imageSelected(board.cover_image_name ?? null)); // );
// }
// if we haven't loaded one full page of images from this board, load more
if (
filteredImages.length < board.image_count &&
filteredImages.length < IMAGES_PER_PAGE
) {
dispatch(
receivedPageOfImages({ categories, board_id, is_intermediate: false })
);
}
}, },
}); });
}; };
@ -68,43 +50,36 @@ export const addBoardIdSelected_changeSelectedImage_listener = () => {
startAppListening({ startAppListening({
actionCreator: boardIdSelected, actionCreator: boardIdSelected,
effect: (action, { getState, dispatch }) => { effect: (action, { getState, dispatch }) => {
const board_id = action.payload; // const board_id = action.payload;
// const state = getState();
const state = getState(); // // we need to check if we need to fetch more images
// if (!board_id) {
// we need to check if we need to fetch more images // // a board was unselected - we don't need to do anything
// return;
if (!board_id) { // }
// a board was unselected - we don't need to do anything // const { categories } = state.gallery;
return; // const filteredImages = selectImagesAll(state).filter((i) => {
} // const isInCategory = categories.includes(i.image_category);
// const isInSelectedBoard = board_id ? i.board_id === board_id : true;
const { categories } = state.gallery; // return isInCategory && isInSelectedBoard;
// });
const filteredImages = selectImagesAll(state).filter((i) => { // // get the board from the cache
const isInCategory = categories.includes(i.image_category); // const { data: boards } =
const isInSelectedBoard = board_id ? i.board_id === board_id : true; // boardsApi.endpoints.listAllBoards.select()(state);
return isInCategory && isInSelectedBoard; // const board = boards?.find((b) => b.board_id === board_id);
}); // if (!board) {
// // can't find the board in cache...
// get the board from the cache // return;
const { data: boards } = // }
boardsApi.endpoints.listAllBoards.select()(state); // // if we haven't loaded one full page of images from this board, load more
const board = boards?.find((b) => b.board_id === board_id); // if (
if (!board) { // filteredImages.length < board.image_count &&
// can't find the board in cache... // filteredImages.length < IMAGES_PER_PAGE
return; // ) {
} // dispatch(
// receivedPageOfImages({ categories, board_id, is_intermediate: false })
// if we haven't loaded one full page of images from this board, load more // );
if ( // }
filteredImages.length < board.image_count &&
filteredImages.length < IMAGES_PER_PAGE
) {
dispatch(
receivedPageOfImages({ categories, board_id, is_intermediate: false })
);
}
}, },
}); });
}; };

View File

@ -1,21 +1,19 @@
import { requestedBoardImagesDeletion } from 'features/gallery/store/actions';
import { startAppListening } from '..';
import {
imageSelected,
imagesRemoved,
selectImagesAll,
selectImagesById,
} from 'features/gallery/store/gallerySlice';
import { resetCanvas } from 'features/canvas/store/canvasSlice'; import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice'; import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import { clearInitialImage } from 'features/parameters/store/generationSlice'; import { requestedBoardImagesDeletion as requestedBoardAndImagesDeletion } from 'features/gallery/store/actions';
import {
imageSelected,
selectImagesById,
} from 'features/gallery/store/gallerySlice';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { LIST_TAG, api } from 'services/api'; import { LIST_TAG, api } from 'services/api';
import { startAppListening } from '..';
import { boardsApi } from '../../../../../services/api/endpoints/boards'; import { boardsApi } from '../../../../../services/api/endpoints/boards';
export const addRequestedBoardImageDeletionListener = () => { export const addRequestedBoardImageDeletionListener = () => {
startAppListening({ startAppListening({
actionCreator: requestedBoardImagesDeletion, actionCreator: requestedBoardAndImagesDeletion,
effect: async (action, { dispatch, getState, condition }) => { effect: async (action, { dispatch, getState, condition }) => {
const { board, imagesUsage } = action.payload; const { board, imagesUsage } = action.payload;
@ -51,20 +49,12 @@ export const addRequestedBoardImageDeletionListener = () => {
dispatch(nodeEditorReset()); dispatch(nodeEditorReset());
} }
// Preemptively remove from gallery
const images = selectImagesAll(state).reduce((acc: string[], img) => {
if (img.board_id === board_id) {
acc.push(img.image_name);
}
return acc;
}, []);
dispatch(imagesRemoved(images));
// Delete from server // Delete from server
dispatch(boardsApi.endpoints.deleteBoardAndImages.initiate(board_id)); dispatch(boardsApi.endpoints.deleteBoardAndImages.initiate(board_id));
const result = const result =
boardsApi.endpoints.deleteBoardAndImages.select(board_id)(state); boardsApi.endpoints.deleteBoardAndImages.select(board_id)(state);
const { isSuccess } = result;
const { isSuccess, data } = result;
// Wait for successful deletion, then trigger boards to re-fetch // Wait for successful deletion, then trigger boards to re-fetch
const wasBoardDeleted = await condition(() => !!isSuccess, 30000); const wasBoardDeleted = await condition(() => !!isSuccess, 30000);

View File

@ -1,10 +1,10 @@
import { canvasSavedToGallery } from 'features/canvas/store/actions';
import { startAppListening } from '..';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { imageUploaded } from 'services/api/thunks/image'; import { canvasSavedToGallery } from 'features/canvas/store/actions';
import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob'; import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob';
import { addToast } from 'features/system/store/systemSlice'; import { addToast } from 'features/system/store/systemSlice';
import { imageUpserted } from 'features/gallery/store/gallerySlice'; import { imagesApi } from 'services/api/endpoints/images';
import { imageUploaded } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'canvasSavedToGalleryListener' }); const moduleLog = log.child({ namespace: 'canvasSavedToGalleryListener' });
@ -49,7 +49,11 @@ export const addCanvasSavedToGalleryListener = () => {
uploadedImageAction.meta.requestId === imageUploadedRequest.requestId uploadedImageAction.meta.requestId === imageUploadedRequest.requestId
); );
dispatch(imageUpserted(uploadedImageDTO)); imagesApi.util.upsertQueryData(
'getImageDTO',
uploadedImageDTO.image_name,
uploadedImageDTO
);
}, },
}); });
}; };

View File

@ -1,13 +1,13 @@
import { startAppListening } from '..';
import { imageMetadataReceived } from 'services/api/thunks/image';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { controlNetImageProcessed } from 'features/controlNet/store/actions'; import { controlNetImageProcessed } from 'features/controlNet/store/actions';
import { Graph } from 'services/api/types';
import { sessionCreated } from 'services/api/thunks/session';
import { sessionReadyToInvoke } from 'features/system/store/actions';
import { socketInvocationComplete } from 'services/events/actions';
import { isImageOutput } from 'services/api/guards';
import { controlNetProcessedImageChanged } from 'features/controlNet/store/controlNetSlice'; import { controlNetProcessedImageChanged } from 'features/controlNet/store/controlNetSlice';
import { sessionReadyToInvoke } from 'features/system/store/actions';
import { isImageOutput } from 'services/api/guards';
import { imageDTOReceived } from 'services/api/thunks/image';
import { sessionCreated } from 'services/api/thunks/session';
import { Graph } from 'services/api/types';
import { socketInvocationComplete } from 'services/events/actions';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'controlNet' }); const moduleLog = log.child({ namespace: 'controlNet' });
@ -63,10 +63,8 @@ export const addControlNetImageProcessedListener = () => {
// Wait for the ImageDTO to be received // Wait for the ImageDTO to be received
const [imageMetadataReceivedAction] = await take( const [imageMetadataReceivedAction] = await take(
( (action): action is ReturnType<typeof imageDTOReceived.fulfilled> =>
action imageDTOReceived.fulfilled.match(action) &&
): action is ReturnType<typeof imageMetadataReceived.fulfilled> =>
imageMetadataReceived.fulfilled.match(action) &&
action.payload.image_name === image_name action.payload.image_name === image_name
); );
const processedControlImage = imageMetadataReceivedAction.payload; const processedControlImage = imageMetadataReceivedAction.payload;

View File

@ -1,40 +0,0 @@
import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
import { imageMetadataReceived } from 'services/api/thunks/image';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
const moduleLog = log.child({ namespace: 'boards' });
export const addImageAddedToBoardFulfilledListener = () => {
startAppListening({
matcher: boardImagesApi.endpoints.addImageToBoard.matchFulfilled,
effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_name } },
'Image added to board'
);
dispatch(
imageMetadataReceived({
image_name,
})
);
},
});
};
export const addImageAddedToBoardRejectedListener = () => {
startAppListening({
matcher: boardImagesApi.endpoints.addImageToBoard.matchRejected,
effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_name } },
'Problem adding image to board'
);
},
});
};

View File

@ -1,13 +1,13 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { imagesApi } from 'services/api/endpoints/images';
import { imageDTOReceived, imageUpdated } from 'services/api/thunks/image';
import { startAppListening } from '..'; import { startAppListening } from '..';
import { imageMetadataReceived, imageUpdated } from 'services/api/thunks/image';
import { imageUpserted } from 'features/gallery/store/gallerySlice';
const moduleLog = log.child({ namespace: 'image' }); const moduleLog = log.child({ namespace: 'image' });
export const addImageMetadataReceivedFulfilledListener = () => { export const addImageDTOReceivedFulfilledListener = () => {
startAppListening({ startAppListening({
actionCreator: imageMetadataReceived.fulfilled, actionCreator: imageDTOReceived.fulfilled,
effect: (action, { getState, dispatch }) => { effect: (action, { getState, dispatch }) => {
const image = action.payload; const image = action.payload;
@ -33,14 +33,14 @@ export const addImageMetadataReceivedFulfilledListener = () => {
} }
moduleLog.debug({ data: { image } }, 'Image metadata received'); moduleLog.debug({ data: { image } }, 'Image metadata received');
dispatch(imageUpserted(image)); imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image);
}, },
}); });
}; };
export const addImageMetadataReceivedRejectedListener = () => { export const addImageDTOReceivedRejectedListener = () => {
startAppListening({ startAppListening({
actionCreator: imageMetadataReceived.rejected, actionCreator: imageDTOReceived.rejected,
effect: (action, { getState, dispatch }) => { effect: (action, { getState, dispatch }) => {
moduleLog.debug( moduleLog.debug(
{ data: { image: action.meta.arg } }, { data: { image: action.meta.arg } },

View File

@ -1,11 +1,8 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { resetCanvas } from 'features/canvas/store/canvasSlice'; import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice'; import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import { import { selectFilteredImages } from 'features/gallery/store/gallerySelectors';
imageRemoved, import { imageSelected } from 'features/gallery/store/gallerySlice';
imageSelected,
selectFilteredImages,
} from 'features/gallery/store/gallerySlice';
import { import {
imageDeletionConfirmed, imageDeletionConfirmed,
isModalOpenChanged, isModalOpenChanged,
@ -80,9 +77,6 @@ export const addRequestedImageDeletionListener = () => {
dispatch(nodeEditorReset()); dispatch(nodeEditorReset());
} }
// Preemptively remove from gallery
dispatch(imageRemoved(image_name));
// Delete from server // Delete from server
const { requestId } = dispatch(imageDeleted({ image_name })); const { requestId } = dispatch(imageDeleted({ image_name }));
@ -91,7 +85,7 @@ export const addRequestedImageDeletionListener = () => {
(action): action is ReturnType<typeof imageDeleted.fulfilled> => (action): action is ReturnType<typeof imageDeleted.fulfilled> =>
imageDeleted.fulfilled.match(action) && imageDeleted.fulfilled.match(action) &&
action.meta.requestId === requestId, action.meta.requestId === requestId,
30000 30_000
); );
if (wasImageDeleted) { if (wasImageDeleted) {

View File

@ -21,57 +21,66 @@ import { startAppListening } from '../';
const moduleLog = log.child({ namespace: 'dnd' }); const moduleLog = log.child({ namespace: 'dnd' });
export const imageDropped = createAction<{ export const dndDropped = createAction<{
overData: TypesafeDroppableData; overData: TypesafeDroppableData;
activeData: TypesafeDraggableData; activeData: TypesafeDraggableData;
}>('dnd/imageDropped'); }>('dnd/dndDropped');
export const addImageDroppedListener = () => { export const addImageDroppedListener = () => {
startAppListening({ startAppListening({
actionCreator: imageDropped, actionCreator: dndDropped,
effect: (action, { dispatch, getState }) => { effect: async (action, { dispatch, getState, take }) => {
const { activeData, overData } = action.payload; const { activeData, overData } = action.payload;
const { actionType } = overData;
const state = getState(); const state = getState();
moduleLog.debug(
{ data: { activeData, overData } },
'Image or selection dropped'
);
// set current image // set current image
if ( if (
actionType === 'SET_CURRENT_IMAGE' && overData.actionType === 'SET_CURRENT_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' && activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO activeData.payload.imageDTO
) { ) {
dispatch(imageSelected(activeData.payload.imageDTO.image_name)); dispatch(imageSelected(activeData.payload.imageDTO.image_name));
return;
} }
// set initial image // set initial image
if ( if (
actionType === 'SET_INITIAL_IMAGE' && overData.actionType === 'SET_INITIAL_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' && activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO activeData.payload.imageDTO
) { ) {
dispatch(initialImageChanged(activeData.payload.imageDTO)); dispatch(initialImageChanged(activeData.payload.imageDTO));
return;
} }
// add image to batch // add image to batch
if ( if (
actionType === 'ADD_TO_BATCH' && overData.actionType === 'ADD_TO_BATCH' &&
activeData.payloadType === 'IMAGE_DTO' && activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO activeData.payload.imageDTO
) { ) {
dispatch(imageAddedToBatch(activeData.payload.imageDTO.image_name)); dispatch(imageAddedToBatch(activeData.payload.imageDTO.image_name));
return;
} }
// add multiple images to batch // add multiple images to batch
if ( if (
actionType === 'ADD_TO_BATCH' && overData.actionType === 'ADD_TO_BATCH' &&
activeData.payloadType === 'GALLERY_SELECTION' activeData.payloadType === 'IMAGE_NAMES'
) { ) {
dispatch(imagesAddedToBatch(state.gallery.selection)); dispatch(imagesAddedToBatch(activeData.payload.image_names));
return;
} }
// set control image // set control image
if ( if (
actionType === 'SET_CONTROLNET_IMAGE' && overData.actionType === 'SET_CONTROLNET_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' && activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO activeData.payload.imageDTO
) { ) {
@ -82,20 +91,22 @@ export const addImageDroppedListener = () => {
controlNetId, controlNetId,
}) })
); );
return;
} }
// set canvas image // set canvas image
if ( if (
actionType === 'SET_CANVAS_INITIAL_IMAGE' && overData.actionType === 'SET_CANVAS_INITIAL_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' && activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO activeData.payload.imageDTO
) { ) {
dispatch(setInitialCanvasImage(activeData.payload.imageDTO)); dispatch(setInitialCanvasImage(activeData.payload.imageDTO));
return;
} }
// set nodes image // set nodes image
if ( if (
actionType === 'SET_NODES_IMAGE' && overData.actionType === 'SET_NODES_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' && activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO activeData.payload.imageDTO
) { ) {
@ -107,11 +118,12 @@ export const addImageDroppedListener = () => {
value: activeData.payload.imageDTO, value: activeData.payload.imageDTO,
}) })
); );
return;
} }
// set multiple nodes images (single image handler) // set multiple nodes images (single image handler)
if ( if (
actionType === 'SET_MULTI_NODES_IMAGE' && overData.actionType === 'SET_MULTI_NODES_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' && activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO activeData.payload.imageDTO
) { ) {
@ -123,43 +135,30 @@ export const addImageDroppedListener = () => {
value: [activeData.payload.imageDTO], value: [activeData.payload.imageDTO],
}) })
); );
return;
} }
// set multiple nodes images (multiple images handler) // set multiple nodes images (multiple images handler)
if ( if (
actionType === 'SET_MULTI_NODES_IMAGE' && overData.actionType === 'SET_MULTI_NODES_IMAGE' &&
activeData.payloadType === 'GALLERY_SELECTION' activeData.payloadType === 'IMAGE_NAMES'
) { ) {
const { fieldName, nodeId } = overData.context; const { fieldName, nodeId } = overData.context;
dispatch( dispatch(
imageCollectionFieldValueChanged({ imageCollectionFieldValueChanged({
nodeId, nodeId,
fieldName, fieldName,
value: state.gallery.selection.map((image_name) => ({ value: activeData.payload.image_names.map((image_name) => ({
image_name, image_name,
})), })),
}) })
); );
return;
} }
// remove image from board
// TODO: remove board_id from `removeImageFromBoard()` endpoint
// TODO: handle multiple images
// if (
// actionType === 'MOVE_BOARD' &&
// activeData.payloadType === 'IMAGE_DTO' &&
// activeData.payload.imageDTO &&
// overData.boardId !== null
// ) {
// const { image_name } = activeData.payload.imageDTO;
// dispatch(
// boardImagesApi.endpoints.removeImageFromBoard.initiate({ image_name })
// );
// }
// add image to board // add image to board
if ( if (
actionType === 'MOVE_BOARD' && overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_DTO' && activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO && activeData.payload.imageDTO &&
overData.context.boardId overData.context.boardId
@ -167,22 +166,89 @@ export const addImageDroppedListener = () => {
const { image_name } = activeData.payload.imageDTO; const { image_name } = activeData.payload.imageDTO;
const { boardId } = overData.context; const { boardId } = overData.context;
dispatch( dispatch(
boardImagesApi.endpoints.addImageToBoard.initiate({ boardImagesApi.endpoints.addBoardImage.initiate({
image_name, image_name,
board_id: boardId, board_id: boardId,
}) })
); );
return;
} }
// add multiple images to board // remove image from board
// TODO: add endpoint if (
// if ( overData.actionType === 'MOVE_BOARD' &&
// actionType === 'ADD_TO_BATCH' && activeData.payloadType === 'IMAGE_DTO' &&
// activeData.payloadType === 'IMAGE_NAMES' && activeData.payload.imageDTO &&
// activeData.payload.imageDTONames overData.context.boardId === null
// ) { ) {
// dispatch(boardImagesApi.endpoints.addImagesToBoard.intiate({})); const { image_name } = activeData.payload.imageDTO;
// } dispatch(
boardImagesApi.endpoints.deleteBoardImage.initiate({ image_name })
);
return;
}
// add gallery selection to board
if (
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId
) {
console.log('adding gallery selection to board');
const board_id = overData.context.boardId;
dispatch(
boardImagesApi.endpoints.addManyBoardImages.initiate({
board_id,
image_names: activeData.payload.image_names,
})
);
return;
}
// remove gallery selection from board
if (
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId === null
) {
console.log('removing gallery selection to board');
dispatch(
boardImagesApi.endpoints.deleteManyBoardImages.initiate({
image_names: activeData.payload.image_names,
})
);
return;
}
// add batch selection to board
if (
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId
) {
const board_id = overData.context.boardId;
dispatch(
boardImagesApi.endpoints.addManyBoardImages.initiate({
board_id,
image_names: activeData.payload.image_names,
})
);
return;
}
// remove batch selection from board
if (
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId === null
) {
dispatch(
boardImagesApi.endpoints.deleteManyBoardImages.initiate({
image_names: activeData.payload.image_names,
})
);
return;
}
}, },
}); });
}; };

View File

@ -1,40 +0,0 @@
import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
import { imageMetadataReceived } from 'services/api/thunks/image';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
const moduleLog = log.child({ namespace: 'boards' });
export const addImageRemovedFromBoardFulfilledListener = () => {
startAppListening({
matcher: boardImagesApi.endpoints.removeImageFromBoard.matchFulfilled,
effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_name } },
'Image added to board'
);
dispatch(
imageMetadataReceived({
image_name,
})
);
},
});
};
export const addImageRemovedFromBoardRejectedListener = () => {
startAppListening({
matcher: boardImagesApi.endpoints.removeImageFromBoard.matchRejected,
effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_name } },
'Problem adding image to board'
);
},
});
};

View File

@ -1,13 +1,13 @@
import { startAppListening } from '..';
import { imageUploaded } from 'services/api/thunks/image';
import { addToast } from 'features/system/store/systemSlice';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { imageUpserted } from 'features/gallery/store/gallerySlice'; import { imageAddedToBatch } from 'features/batch/store/batchSlice';
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice'; import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice'; import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { fieldValueChanged } from 'features/nodes/store/nodesSlice'; import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
import { imageAddedToBatch } from 'features/batch/store/batchSlice'; import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { addToast } from 'features/system/store/systemSlice';
import { imagesApi } from 'services/api/endpoints/images';
import { imageUploaded } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'image' }); const moduleLog = log.child({ namespace: 'image' });
@ -24,7 +24,8 @@ export const addImageUploadedFulfilledListener = () => {
return; return;
} }
dispatch(imageUpserted(image)); // update RTK query cache
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image);
const { postUploadAction } = action.meta.arg; const { postUploadAction } = action.meta.arg;
@ -84,8 +85,8 @@ export const addImageUploadedRejectedListener = () => {
startAppListening({ startAppListening({
actionCreator: imageUploaded.rejected, actionCreator: imageUploaded.rejected,
effect: (action, { dispatch }) => { effect: (action, { dispatch }) => {
const { formData, ...rest } = action.meta.arg; const { file, ...rest } = action.meta.arg;
const sanitizedData = { arg: { ...rest, formData: { file: '<Blob>' } } }; const sanitizedData = { arg: { ...rest, file: '<Blob>' } };
moduleLog.error({ data: sanitizedData }, 'Image upload failed'); moduleLog.error({ data: sanitizedData }, 'Image upload failed');
dispatch( dispatch(
addToast({ addToast({

View File

@ -1,37 +0,0 @@
import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
import { imageUrlsReceived } from 'services/api/thunks/image';
import { imageUpdatedOne } from 'features/gallery/store/gallerySlice';
const moduleLog = log.child({ namespace: 'image' });
export const addImageUrlsReceivedFulfilledListener = () => {
startAppListening({
actionCreator: imageUrlsReceived.fulfilled,
effect: (action, { getState, dispatch }) => {
const image = action.payload;
moduleLog.debug({ data: { image } }, 'Image URLs received');
const { image_name, image_url, thumbnail_url } = image;
dispatch(
imageUpdatedOne({
id: image_name,
changes: { image_url, thumbnail_url },
})
);
},
});
};
export const addImageUrlsReceivedRejectedListener = () => {
startAppListening({
actionCreator: imageUrlsReceived.rejected,
effect: (action, { getState, dispatch }) => {
moduleLog.debug(
{ data: { image: action.meta.arg } },
'Problem getting image URLs'
);
},
});
};

View File

@ -0,0 +1,38 @@
import { log } from 'app/logging/useLogger';
import { serializeError } from 'serialize-error';
import { imagesApi } from 'services/api/endpoints/images';
import { imagesLoaded } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'gallery' });
export const addImagesLoadedListener = () => {
startAppListening({
actionCreator: imagesLoaded.fulfilled,
effect: (action, { getState, dispatch }) => {
const { items } = action.payload;
moduleLog.debug(
{ data: { payload: action.payload } },
`Loaded ${items.length} images`
);
items.forEach((image) => {
dispatch(
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image)
);
});
},
});
startAppListening({
actionCreator: imagesLoaded.rejected,
effect: (action, { getState, dispatch }) => {
if (action.payload) {
moduleLog.debug(
{ data: { error: serializeError(action.payload) } },
'Problem loading images'
);
}
},
});
};

View File

@ -0,0 +1,38 @@
import { log } from 'app/logging/useLogger';
import { serializeError } from 'serialize-error';
import { imagesApi } from 'services/api/endpoints/images';
import { receivedListOfImages } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'gallery' });
export const addReceivedListOfImagesListener = () => {
startAppListening({
actionCreator: receivedListOfImages.fulfilled,
effect: (action, { getState, dispatch }) => {
const { image_dtos } = action.payload;
moduleLog.debug(
{ data: { payload: action.payload } },
`Received ${image_dtos.length} images`
);
image_dtos.forEach((image) => {
dispatch(
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image)
);
});
},
});
startAppListening({
actionCreator: receivedListOfImages.rejected,
effect: (action, { getState, dispatch }) => {
if (action.payload) {
moduleLog.debug(
{ data: { error: serializeError(action.payload) } },
'Problem receiving images'
);
}
},
});
};

View File

@ -1,12 +1,12 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
import { serializeError } from 'serialize-error'; import { serializeError } from 'serialize-error';
import { receivedPageOfImages } from 'services/api/thunks/image';
import { imagesApi } from 'services/api/endpoints/images'; import { imagesApi } from 'services/api/endpoints/images';
import { receivedPageOfImages } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'gallery' }); const moduleLog = log.child({ namespace: 'gallery' });
export const addReceivedPageOfImagesFulfilledListener = () => { export const addReceivedPageOfImagesListener = () => {
startAppListening({ startAppListening({
actionCreator: receivedPageOfImages.fulfilled, actionCreator: receivedPageOfImages.fulfilled,
effect: (action, { getState, dispatch }) => { effect: (action, { getState, dispatch }) => {
@ -16,6 +16,8 @@ export const addReceivedPageOfImagesFulfilledListener = () => {
`Received ${items.length} images` `Received ${items.length} images`
); );
// inject the received images into the RTK Query cache so consumers of the useGetImageDTOQuery
// hook can get their data from the cache instead of fetching the data again
items.forEach((image) => { items.forEach((image) => {
dispatch( dispatch(
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image) imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image)
@ -23,9 +25,7 @@ export const addReceivedPageOfImagesFulfilledListener = () => {
}); });
}, },
}); });
};
export const addReceivedPageOfImagesRejectedListener = () => {
startAppListening({ startAppListening({
actionCreator: receivedPageOfImages.rejected, actionCreator: receivedPageOfImages.rejected,
effect: (action, { getState, dispatch }) => { effect: (action, { getState, dispatch }) => {

View File

@ -1,19 +1,38 @@
import { startAppListening } from '..'; import { createAction } from '@reduxjs/toolkit';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { import { imagesAddedToBatch } from 'features/batch/store/batchSlice';
imagesAddedToBatch, import { imagesApi } from 'services/api/endpoints/images';
selectionAddedToBatch, import { receivedListOfImages } from 'services/api/thunks/image';
} from 'features/batch/store/batchSlice'; import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'batch' }); const moduleLog = log.child({ namespace: 'batch' });
export const selectionAddedToBatch = createAction<{ images_names: string[] }>(
'batch/selectionAddedToBatch'
);
export const addSelectionAddedToBatchListener = () => { export const addSelectionAddedToBatchListener = () => {
startAppListening({ startAppListening({
actionCreator: selectionAddedToBatch, actionCreator: selectionAddedToBatch,
effect: (action, { dispatch, getState }) => { effect: async (action, { dispatch, getState, take }) => {
const { selection } = getState().gallery; const { requestId } = dispatch(
receivedListOfImages(action.payload.images_names)
);
dispatch(imagesAddedToBatch(selection)); const [{ payload }] = await take(
(action): action is ReturnType<typeof receivedListOfImages.fulfilled> =>
action.meta.requestId === requestId
);
moduleLog.debug({ data: { payload } }, 'receivedListOfImages');
dispatch(imagesAddedToBatch(payload.image_dtos));
payload.image_dtos.forEach((image) => {
dispatch(
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image)
);
});
}, },
}); });
}; };

View File

@ -1,15 +1,16 @@
import { addImageToStagingArea } from 'features/canvas/store/canvasSlice';
import { startAppListening } from '../..';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { addImageToStagingArea } from 'features/canvas/store/canvasSlice';
import { progressImageSet } from 'features/system/store/systemSlice';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
import { imagesApi } from 'services/api/endpoints/images';
import { isImageOutput } from 'services/api/guards';
import { imageDTOReceived } from 'services/api/thunks/image';
import { sessionCanceled } from 'services/api/thunks/session';
import { import {
appSocketInvocationComplete, appSocketInvocationComplete,
socketInvocationComplete, socketInvocationComplete,
} from 'services/events/actions'; } from 'services/events/actions';
import { imageMetadataReceived } from 'services/api/thunks/image'; import { startAppListening } from '../..';
import { sessionCanceled } from 'services/api/thunks/session';
import { isImageOutput } from 'services/api/guards';
import { progressImageSet } from 'features/system/store/systemSlice';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
const moduleLog = log.child({ namespace: 'socketio' }); const moduleLog = log.child({ namespace: 'socketio' });
const nodeDenylist = ['dataURL_image']; const nodeDenylist = ['dataURL_image'];
@ -41,14 +42,16 @@ export const addInvocationCompleteEventListener = () => {
const { image_name } = result.image; const { image_name } = result.image;
// Get its metadata // Get its metadata
dispatch( const { requestId } = dispatch(
imageMetadataReceived({ imageDTOReceived({
image_name, image_name,
}) })
); );
const [{ payload: imageDTO }] = await take( const [{ payload: imageDTO }] = await take(
imageMetadataReceived.fulfilled.match (action): action is ReturnType<typeof imageDTOReceived.fulfilled> =>
imageDTOReceived.fulfilled.match(action) &&
action.meta.requestId === requestId
); );
// Handle canvas image // Handle canvas image
@ -59,13 +62,33 @@ export const addInvocationCompleteEventListener = () => {
dispatch(addImageToStagingArea(imageDTO)); dispatch(addImageToStagingArea(imageDTO));
} }
// Update the RTK Query cache
dispatch(
imagesApi.util.upsertQueryData(
'getImageDTO',
imageDTO.image_name,
imageDTO
)
);
if (boardIdToAddTo && !imageDTO.is_intermediate) { if (boardIdToAddTo && !imageDTO.is_intermediate) {
dispatch( dispatch(
boardImagesApi.endpoints.addImageToBoard.initiate({ boardImagesApi.endpoints.addBoardImage.initiate({
board_id: boardIdToAddTo, board_id: boardIdToAddTo,
image_name, image_name,
}) })
); );
// Set the board_id on the image in the RTK Query cache
dispatch(
imagesApi.util.updateQueryData(
'getImageDTO',
imageDTO.image_name,
(draft) => {
Object.assign(draft, { board_id: boardIdToAddTo });
}
)
);
} }
dispatch(progressImageSet(null)); dispatch(progressImageSet(null));

View File

@ -1,9 +1,9 @@
import { stagingAreaImageSaved } from 'features/canvas/store/actions';
import { startAppListening } from '..';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { imageUpdated } from 'services/api/thunks/image'; import { stagingAreaImageSaved } from 'features/canvas/store/actions';
import { imageUpserted } from 'features/gallery/store/gallerySlice';
import { addToast } from 'features/system/store/systemSlice'; import { addToast } from 'features/system/store/systemSlice';
import { imagesApi } from 'services/api/endpoints/images';
import { imageUpdated } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'canvas' }); const moduleLog = log.child({ namespace: 'canvas' });
@ -43,7 +43,10 @@ export const addStagingAreaImageSavedListener = () => {
} }
if (imageUpdated.fulfilled.match(imageUpdatedAction)) { if (imageUpdated.fulfilled.match(imageUpdatedAction)) {
dispatch(imageUpserted(imageUpdatedAction.payload)); // update cache
imagesApi.util.updateQueryData('getImageDTO', imageName, (draft) => {
Object.assign(draft, { is_intermediate: false });
});
dispatch(addToast({ title: 'Image Saved', status: 'success' })); dispatch(addToast({ title: 'Image Saved', status: 'success' }));
} }
}, },

View File

@ -96,10 +96,26 @@ export const store = configureStore({
.concat(dynamicMiddlewares) .concat(dynamicMiddlewares)
.prepend(listenerMiddleware.middleware), .prepend(listenerMiddleware.middleware),
devTools: { devTools: {
actionsDenylist,
actionSanitizer, actionSanitizer,
stateSanitizer, stateSanitizer,
trace: true, trace: true,
predicate: (state, action) => {
// TODO: hook up to the log level param in system slice
// manually type state, cannot type the arg
// const typedState = state as ReturnType<typeof rootReducer>;
// if (action.type.startsWith('api/')) {
// // don't log api actions, with manual cache updates they are extremely noisy
// return false;
// }
if (actionsDenylist.includes(action.type)) {
// don't log other noisy actions
return false;
}
return true;
},
}, },
}); });

View File

@ -6,30 +6,24 @@ import {
useColorMode, useColorMode,
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useCombinedRefs } from '@dnd-kit/utilities'; import {
TypesafeDraggableData,
TypesafeDroppableData,
} from 'app/components/ImageDnd/typesafeDnd';
import IAIIconButton from 'common/components/IAIIconButton'; import IAIIconButton from 'common/components/IAIIconButton';
import { import {
IAILoadingImageFallback, IAILoadingImageFallback,
IAINoContentFallback, IAINoContentFallback,
} from 'common/components/IAIImageFallback'; } from 'common/components/IAIImageFallback';
import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay'; import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay';
import { AnimatePresence } from 'framer-motion';
import { MouseEvent, ReactElement, SyntheticEvent } from 'react';
import { memo, useRef } from 'react';
import { FaImage, FaUndo, FaUpload } from 'react-icons/fa';
import { ImageDTO } from 'services/api/types';
import { v4 as uuidv4 } from 'uuid';
import IAIDropOverlay from './IAIDropOverlay';
import { PostUploadAction } from 'services/api/thunks/image';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { MouseEvent, ReactElement, SyntheticEvent, memo } from 'react';
import { FaImage, FaUndo, FaUpload } from 'react-icons/fa';
import { PostUploadAction } from 'services/api/thunks/image';
import { ImageDTO } from 'services/api/types';
import { mode } from 'theme/util/mode'; import { mode } from 'theme/util/mode';
import { import IAIDraggable from './IAIDraggable';
TypesafeDraggableData, import IAIDroppable from './IAIDroppable';
TypesafeDroppableData,
isValidDrop,
useDraggable,
useDroppable,
} from 'app/components/ImageDnd/typesafeDnd';
type IAIDndImageProps = { type IAIDndImageProps = {
imageDTO: ImageDTO | undefined; imageDTO: ImageDTO | undefined;
@ -83,28 +77,6 @@ const IAIDndImage = (props: IAIDndImageProps) => {
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
const dndId = useRef(uuidv4());
const {
attributes,
listeners,
setNodeRef: setDraggableRef,
isDragging,
active,
} = useDraggable({
id: dndId.current,
disabled: isDragDisabled || !imageDTO,
data: draggableData,
});
const { isOver, setNodeRef: setDroppableRef } = useDroppable({
id: dndId.current,
disabled: isDropDisabled,
data: droppableData,
});
const setDndRef = useCombinedRefs(setDroppableRef, setDraggableRef);
const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({ const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
postUploadAction, postUploadAction,
isDisabled: isUploadDisabled, isDisabled: isUploadDisabled,
@ -139,9 +111,6 @@ const IAIDndImage = (props: IAIDndImageProps) => {
userSelect: 'none', userSelect: 'none',
cursor: isDragDisabled || !imageDTO ? 'default' : 'pointer', cursor: isDragDisabled || !imageDTO ? 'default' : 'pointer',
}} }}
{...attributes}
{...listeners}
ref={setDndRef}
> >
{imageDTO && ( {imageDTO && (
<Flex <Flex
@ -154,7 +123,6 @@ const IAIDndImage = (props: IAIDndImageProps) => {
}} }}
> >
<Image <Image
onClick={onClick}
src={thumbnail ? imageDTO.thumbnail_url : imageDTO.image_url} src={thumbnail ? imageDTO.thumbnail_url : imageDTO.image_url}
fallbackStrategy="beforeLoadOrError" fallbackStrategy="beforeLoadOrError"
fallback={<IAILoadingImageFallback image={imageDTO} />} fallback={<IAILoadingImageFallback image={imageDTO} />}
@ -171,30 +139,6 @@ const IAIDndImage = (props: IAIDndImageProps) => {
}} }}
/> />
{withMetadataOverlay && <ImageMetadataOverlay image={imageDTO} />} {withMetadataOverlay && <ImageMetadataOverlay image={imageDTO} />}
{onClickReset && withResetIcon && (
<IAIIconButton
onClick={onClickReset}
aria-label={resetTooltip}
tooltip={resetTooltip}
icon={resetIcon}
size="sm"
variant="link"
sx={{
position: 'absolute',
top: 1,
insetInlineEnd: 1,
p: 0,
minW: 0,
svg: {
transitionProperty: 'common',
transitionDuration: 'normal',
fill: 'base.100',
_hover: { fill: 'base.50' },
filter: resetIconShadow,
},
}}
/>
)}
</Flex> </Flex>
)} )}
{!imageDTO && !isUploadDisabled && ( {!imageDTO && !isUploadDisabled && (
@ -225,11 +169,42 @@ const IAIDndImage = (props: IAIDndImageProps) => {
</> </>
)} )}
{!imageDTO && isUploadDisabled && noContentFallback} {!imageDTO && isUploadDisabled && noContentFallback}
<AnimatePresence> <IAIDroppable
{isValidDrop(droppableData, active) && !isDragging && ( data={droppableData}
<IAIDropOverlay isOver={isOver} label={dropLabel} /> disabled={isDropDisabled}
)} dropLabel={dropLabel}
</AnimatePresence> />
{imageDTO && (
<IAIDraggable
data={draggableData}
disabled={isDragDisabled || !imageDTO}
onClick={onClick}
/>
)}
{onClickReset && withResetIcon && imageDTO && (
<IAIIconButton
onClick={onClickReset}
aria-label={resetTooltip}
tooltip={resetTooltip}
icon={resetIcon}
size="sm"
variant="link"
sx={{
position: 'absolute',
top: 1,
insetInlineEnd: 1,
p: 0,
minW: 0,
svg: {
transitionProperty: 'common',
transitionDuration: 'normal',
fill: 'base.100',
_hover: { fill: 'base.50' },
filter: resetIconShadow,
},
}}
/>
)}
</Flex> </Flex>
); );
}; };

View File

@ -0,0 +1,40 @@
import { Box } from '@chakra-ui/react';
import {
TypesafeDraggableData,
useDraggable,
} from 'app/components/ImageDnd/typesafeDnd';
import { MouseEvent, memo, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
type IAIDraggableProps = {
disabled?: boolean;
data?: TypesafeDraggableData;
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
};
const IAIDraggable = (props: IAIDraggableProps) => {
const { data, disabled, onClick } = props;
const dndId = useRef(uuidv4());
const { attributes, listeners, setNodeRef } = useDraggable({
id: dndId.current,
disabled,
data,
});
return (
<Box
onClick={onClick}
ref={setNodeRef}
position="absolute"
w="full"
h="full"
top={0}
insetInlineStart={0}
{...attributes}
{...listeners}
/>
);
};
export default memo(IAIDraggable);

View File

@ -0,0 +1,47 @@
import { Box } from '@chakra-ui/react';
import {
TypesafeDroppableData,
isValidDrop,
useDroppable,
} from 'app/components/ImageDnd/typesafeDnd';
import { AnimatePresence } from 'framer-motion';
import { memo, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
import IAIDropOverlay from './IAIDropOverlay';
type IAIDroppableProps = {
dropLabel?: string;
disabled?: boolean;
data?: TypesafeDroppableData;
};
const IAIDroppable = (props: IAIDroppableProps) => {
const { dropLabel, data, disabled } = props;
const dndId = useRef(uuidv4());
const { isOver, setNodeRef, active } = useDroppable({
id: dndId.current,
disabled,
data,
});
return (
<Box
ref={setNodeRef}
position="absolute"
top={0}
insetInlineStart={0}
w="full"
h="full"
pointerEvents="none"
>
<AnimatePresence>
{isValidDrop(data, active) && (
<IAIDropOverlay isOver={isOver} label={dropLabel} />
)}
</AnimatePresence>
</Box>
);
};
export default memo(IAIDroppable);

View File

@ -0,0 +1,42 @@
import { Box, Flex, Icon } from '@chakra-ui/react';
import { FaExclamation } from 'react-icons/fa';
const IAIErrorLoadingImageFallback = () => {
return (
<Box
sx={{
position: 'relative',
height: 'full',
width: 'full',
'::before': {
content: "''",
display: 'block',
pt: '100%',
},
}}
>
<Flex
sx={{
position: 'absolute',
top: 0,
insetInlineStart: 0,
height: 'full',
width: 'full',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'base',
bg: 'base.100',
color: 'base.500',
_dark: {
color: 'base.700',
bg: 'base.850',
},
}}
>
<Icon as={FaExclamation} boxSize={16} opacity={0.7} />
</Flex>
</Box>
);
};
export default IAIErrorLoadingImageFallback;

View File

@ -0,0 +1,30 @@
import { Box, Skeleton } from '@chakra-ui/react';
const IAIFillSkeleton = () => {
return (
<Skeleton
sx={{
position: 'relative',
height: 'full',
width: 'full',
'::before': {
content: "''",
display: 'block',
pt: '100%',
},
}}
>
<Box
sx={{
position: 'absolute',
top: 0,
insetInlineStart: 0,
height: 'full',
width: 'full',
}}
/>
</Skeleton>
);
};
export default IAIFillSkeleton;

View File

@ -1,18 +1,20 @@
import { Box, Icon, Skeleton } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd'; import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppDispatch, 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 IAIDndImage from 'common/components/IAIDndImage'; import IAIDndImage from 'common/components/IAIDndImage';
import IAIErrorLoadingImageFallback from 'common/components/IAIErrorLoadingImageFallback';
import IAIFillSkeleton from 'common/components/IAIFillSkeleton';
import { import {
batchImageRangeEndSelected, batchImageRangeEndSelected,
batchImageSelected, batchImageSelected,
batchImageSelectionToggled, batchImageSelectionToggled,
imageRemovedFromBatch, imageRemovedFromBatch,
} from 'features/batch/store/batchSlice'; } from 'features/batch/store/batchSlice';
import ImageContextMenu from 'features/gallery/components/ImageContextMenu';
import { MouseEvent, memo, useCallback, useMemo } from 'react'; import { MouseEvent, memo, useCallback, useMemo } from 'react';
import { FaExclamationCircle } from 'react-icons/fa';
import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useGetImageDTOQuery } from 'services/api/endpoints/images';
const makeSelector = (image_name: string) => const makeSelector = (image_name: string) =>
@ -20,6 +22,7 @@ const makeSelector = (image_name: string) =>
[stateSelector], [stateSelector],
(state) => ({ (state) => ({
selectionCount: state.batch.selection.length, selectionCount: state.batch.selection.length,
selection: state.batch.selection,
isSelected: state.batch.selection.includes(image_name), isSelected: state.batch.selection.includes(image_name),
}), }),
defaultSelectorOptions defaultSelectorOptions
@ -30,43 +33,41 @@ type BatchImageProps = {
}; };
const BatchImage = (props: BatchImageProps) => { const BatchImage = (props: BatchImageProps) => {
const dispatch = useAppDispatch();
const { imageName } = props;
const { const {
currentData: imageDTO, currentData: imageDTO,
isFetching, isLoading,
isError, isError,
isSuccess, isSuccess,
} = useGetImageDTOQuery(props.imageName); } = useGetImageDTOQuery(imageName);
const dispatch = useAppDispatch(); const selector = useMemo(() => makeSelector(imageName), [imageName]);
const selector = useMemo( const { isSelected, selectionCount, selection } = useAppSelector(selector);
() => makeSelector(props.imageName),
[props.imageName]
);
const { isSelected, selectionCount } = useAppSelector(selector);
const handleClickRemove = useCallback(() => { const handleClickRemove = useCallback(() => {
dispatch(imageRemovedFromBatch(props.imageName)); dispatch(imageRemovedFromBatch(imageName));
}, [dispatch, props.imageName]); }, [dispatch, imageName]);
const handleClick = useCallback( const handleClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => { (e: MouseEvent<HTMLDivElement>) => {
if (e.shiftKey) { if (e.shiftKey) {
dispatch(batchImageRangeEndSelected(props.imageName)); dispatch(batchImageRangeEndSelected(imageName));
} else if (e.ctrlKey || e.metaKey) { } else if (e.ctrlKey || e.metaKey) {
dispatch(batchImageSelectionToggled(props.imageName)); dispatch(batchImageSelectionToggled(imageName));
} else { } else {
dispatch(batchImageSelected(props.imageName)); dispatch(batchImageSelected(imageName));
} }
}, },
[dispatch, props.imageName] [dispatch, imageName]
); );
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => { const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {
if (selectionCount > 1) { if (selectionCount > 1) {
return { return {
id: 'batch', id: 'batch',
payloadType: 'BATCH_SELECTION', payloadType: 'IMAGE_NAMES',
payload: { image_names: selection },
}; };
} }
@ -77,38 +78,49 @@ const BatchImage = (props: BatchImageProps) => {
payload: { imageDTO }, payload: { imageDTO },
}; };
} }
}, [imageDTO, selectionCount]); }, [imageDTO, selection, selectionCount]);
if (isError) { if (isLoading) {
return <Icon as={FaExclamationCircle} />; return <IAIFillSkeleton />;
} }
if (isFetching) { if (isError || !imageDTO) {
return ( return <IAIErrorLoadingImageFallback />;
<Skeleton>
<Box w="full" h="full" aspectRatio="1/1" />
</Skeleton>
);
} }
return ( return (
<Box sx={{ position: 'relative', aspectRatio: '1/1' }}> <Box sx={{ w: 'full', h: 'full', touchAction: 'none' }}>
<IAIDndImage <ImageContextMenu imageDTO={imageDTO}>
imageDTO={imageDTO} {(ref) => (
draggableData={draggableData} <Box
isDropDisabled={true} position="relative"
isUploadDisabled={true} key={imageName}
imageSx={{ userSelect="none"
w: 'full', ref={ref}
h: 'full', sx={{
}} display: 'flex',
onClick={handleClick} justifyContent: 'center',
isSelected={isSelected} alignItems: 'center',
onClickReset={handleClickRemove} aspectRatio: '1/1',
resetTooltip="Remove from batch" }}
withResetIcon >
thumbnail <IAIDndImage
/> onClick={handleClick}
imageDTO={imageDTO}
draggableData={draggableData}
isSelected={isSelected}
minSize={0}
onClickReset={handleClickRemove}
isDropDisabled={true}
imageSx={{ w: 'full', h: 'full' }}
isUploadDisabled={true}
resetTooltip="Remove from batch"
withResetIcon
thumbnail
/>
</Box>
)}
</ImageContextMenu>
</Box> </Box>
); );
}; };

View File

@ -1,11 +1,7 @@
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import { AddToBatchDropData } from 'app/components/ImageDnd/typesafeDnd';
import IAIDroppable from 'common/components/IAIDroppable';
import BatchImageGrid from './BatchImageGrid'; import BatchImageGrid from './BatchImageGrid';
import IAIDropOverlay from 'common/components/IAIDropOverlay';
import {
AddToBatchDropData,
isValidDrop,
useDroppable,
} from 'app/components/ImageDnd/typesafeDnd';
const droppableData: AddToBatchDropData = { const droppableData: AddToBatchDropData = {
id: 'batch', id: 'batch',
@ -13,17 +9,10 @@ const droppableData: AddToBatchDropData = {
}; };
const BatchImageContainer = () => { const BatchImageContainer = () => {
const { isOver, setNodeRef, active } = useDroppable({
id: 'batch-manager',
data: droppableData,
});
return ( return (
<Box ref={setNodeRef} position="relative" w="full" h="full"> <Box position="relative" w="full" h="full">
<BatchImageGrid /> <BatchImageGrid />
{isValidDrop(droppableData, active) && ( <IAIDroppable data={droppableData} dropLabel="Add to Batch" />
<IAIDropOverlay isOver={isOver} label="Add to Batch" />
)}
</Box> </Box>
); );
}; };

View File

@ -1,4 +1,4 @@
import { PayloadAction, createAction, createSlice } from '@reduxjs/toolkit'; import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { uniq } from 'lodash-es'; import { uniq } from 'lodash-es';
import { imageDeleted } from 'services/api/thunks/image'; import { imageDeleted } from 'services/api/thunks/image';
@ -26,10 +26,10 @@ const batch = createSlice({
state.isEnabled = action.payload; state.isEnabled = action.payload;
}, },
imageAddedToBatch: (state, action: PayloadAction<string>) => { imageAddedToBatch: (state, action: PayloadAction<string>) => {
state.imageNames = uniq(state.imageNames.concat(action.payload)); state.imageNames.push(action.payload);
}, },
imagesAddedToBatch: (state, action: PayloadAction<string[]>) => { imagesAddedToBatch: (state, action: PayloadAction<string[]>) => {
state.imageNames = uniq(state.imageNames.concat(action.payload)); state.imageNames = state.imageNames.concat(action.payload);
}, },
imageRemovedFromBatch: (state, action: PayloadAction<string>) => { imageRemovedFromBatch: (state, action: PayloadAction<string>) => {
state.imageNames = state.imageNames.filter( state.imageNames = state.imageNames.filter(
@ -50,10 +50,13 @@ const batch = createSlice({
batchImageRangeEndSelected: (state, action: PayloadAction<string>) => { batchImageRangeEndSelected: (state, action: PayloadAction<string>) => {
const rangeEndImageName = action.payload; const rangeEndImageName = action.payload;
const lastSelectedImage = state.selection[state.selection.length - 1]; const lastSelectedImage = state.selection[state.selection.length - 1];
const lastClickedIndex = state.imageNames.findIndex(
const { imageNames } = state;
const lastClickedIndex = imageNames.findIndex(
(n) => n === lastSelectedImage (n) => n === lastSelectedImage
); );
const currentClickedIndex = state.imageNames.findIndex( const currentClickedIndex = imageNames.findIndex(
(n) => n === rangeEndImageName (n) => n === rangeEndImageName
); );
if (lastClickedIndex > -1 && currentClickedIndex > -1) { if (lastClickedIndex > -1 && currentClickedIndex > -1) {
@ -61,7 +64,8 @@ const batch = createSlice({
const start = Math.min(lastClickedIndex, currentClickedIndex); const start = Math.min(lastClickedIndex, currentClickedIndex);
const end = Math.max(lastClickedIndex, currentClickedIndex); const end = Math.max(lastClickedIndex, currentClickedIndex);
const imagesToSelect = state.imageNames.slice(start, end + 1); const imagesToSelect = imageNames.slice(start, end + 1);
state.selection = uniq(state.selection.concat(imagesToSelect)); state.selection = uniq(state.selection.concat(imagesToSelect));
} }
}, },
@ -136,7 +140,3 @@ export const {
} = batch.actions; } = batch.actions;
export default batch.reducer; export default batch.reducer;
export const selectionAddedToBatch = createAction(
'batch/selectionAddedToBatch'
);

View File

@ -0,0 +1,87 @@
import { Box } from '@chakra-ui/react';
import { useAppSelector } from 'app/store/storeHooks';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { memo, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaImage } from 'react-icons/fa';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import BatchImage from 'features/batch/components/BatchImage';
import { VirtuosoGrid } from 'react-virtuoso';
import ItemContainer from './ItemContainer';
import ListContainer from './ListContainer';
const selector = createSelector(
[stateSelector],
(state) => {
return {
imageNames: state.batch.imageNames,
};
},
defaultSelectorOptions
);
const BatchGrid = () => {
const { t } = useTranslation();
const rootRef = useRef(null);
const [scroller, setScroller] = useState<HTMLElement | null>(null);
const [initialize, osInstance] = useOverlayScrollbars({
defer: true,
options: {
scrollbars: {
visibility: 'auto',
autoHide: 'leave',
autoHideDelay: 1300,
theme: 'os-theme-dark',
},
overflow: { x: 'hidden' },
},
});
const { imageNames } = useAppSelector(selector);
useEffect(() => {
const { current: root } = rootRef;
if (scroller && root) {
initialize({
target: root,
elements: {
viewport: scroller,
},
});
}
return () => osInstance()?.destroy();
}, [scroller, initialize, osInstance]);
if (imageNames.length) {
return (
<Box ref={rootRef} data-overlayscrollbars="" h="100%">
<VirtuosoGrid
style={{ height: '100%' }}
data={imageNames}
components={{
Item: ItemContainer,
List: ListContainer,
}}
scrollerRef={setScroller}
itemContent={(index, imageName) => (
<BatchImage key={imageName} imageName={imageName} />
)}
/>
</Box>
);
}
return (
<IAINoContentFallback
label={t('gallery.noImagesInGallery')}
icon={FaImage}
/>
);
};
export default memo(BatchGrid);

View File

@ -0,0 +1,102 @@
import { Box, Spinner } from '@chakra-ui/react';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { memo, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaExclamation, FaImage } from 'react-icons/fa';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import BatchImage from 'features/batch/components/BatchImage';
import { VirtuosoGrid } from 'react-virtuoso';
import { useGetAllBoardImagesForBoardQuery } from 'services/api/endpoints/boardImages';
import ItemContainer from './ItemContainer';
import ListContainer from './ListContainer';
const selector = createSelector(
[stateSelector],
(state) => {
return {
imageNames: state.batch.imageNames,
};
},
defaultSelectorOptions
);
type BoardGridProps = {
board_id: string;
};
const BoardGrid = (props: BoardGridProps) => {
const { board_id } = props;
const { data, isLoading, isError, isSuccess } =
useGetAllBoardImagesForBoardQuery({
board_id,
});
const { t } = useTranslation();
const rootRef = useRef(null);
const [scroller, setScroller] = useState<HTMLElement | null>(null);
const [initialize, osInstance] = useOverlayScrollbars({
defer: true,
options: {
scrollbars: {
visibility: 'auto',
autoHide: 'leave',
autoHideDelay: 1300,
theme: 'os-theme-dark',
},
overflow: { x: 'hidden' },
},
});
useEffect(() => {
const { current: root } = rootRef;
if (scroller && root) {
initialize({
target: root,
elements: {
viewport: scroller,
},
});
}
return () => osInstance()?.destroy();
}, [scroller, initialize, osInstance]);
if (isLoading) {
return <Spinner />;
}
if (isError) {
return <FaExclamation />;
}
if (isSuccess && data.image_names) {
return (
<Box ref={rootRef} data-overlayscrollbars="" h="100%">
<VirtuosoGrid
style={{ height: '100%' }}
data={data.image_names}
components={{
Item: ItemContainer,
List: ListContainer,
}}
scrollerRef={setScroller}
itemContent={(index, imageName) => (
<BatchImage key={imageName} imageName={imageName} />
)}
/>
</Box>
);
}
return (
<IAINoContentFallback
label={t('gallery.noImagesInGallery')}
icon={FaImage}
/>
);
};
export default memo(BoardGrid);

View File

@ -1,23 +1,14 @@
import { Flex, useColorMode } from '@chakra-ui/react'; import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
import { FaImages } from 'react-icons/fa';
import { boardIdSelected } from 'features/gallery/store/gallerySlice'; import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { FaImages } from 'react-icons/fa';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import GenericBoard from './GenericBoard';
import { AnimatePresence } from 'framer-motion';
import IAIDropOverlay from 'common/components/IAIDropOverlay';
import { mode } from 'theme/util/mode';
import {
MoveBoardDropData,
isValidDrop,
useDroppable,
} from 'app/components/ImageDnd/typesafeDnd';
const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => { const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { colorMode } = useColorMode();
const handleAllImagesBoardClick = () => { const handleAllImagesBoardClick = () => {
dispatch(boardIdSelected()); dispatch(boardIdSelected('all'));
}; };
const droppableData: MoveBoardDropData = { const droppableData: MoveBoardDropData = {
@ -26,67 +17,14 @@ const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
context: { boardId: null }, context: { boardId: null },
}; };
const { isOver, setNodeRef, active } = useDroppable({
id: `board_droppable_all_images`,
data: droppableData,
});
return ( return (
<Flex <GenericBoard
sx={{ droppableData={droppableData}
flexDir: 'column', onClick={handleAllImagesBoardClick}
justifyContent: 'space-between', isSelected={isSelected}
alignItems: 'center', icon={FaImages}
cursor: 'pointer', label="All Images"
w: 'full', />
h: 'full',
borderRadius: 'base',
}}
>
<Flex
ref={setNodeRef}
onClick={handleAllImagesBoardClick}
sx={{
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
borderRadius: 'base',
w: 'full',
aspectRatio: '1/1',
overflow: 'hidden',
shadow: isSelected ? 'selected.light' : undefined,
_dark: { shadow: isSelected ? 'selected.dark' : undefined },
flexShrink: 0,
}}
>
<IAINoContentFallback
boxSize={8}
icon={FaImages}
sx={{
border: '2px solid var(--invokeai-colors-base-200)',
_dark: { border: '2px solid var(--invokeai-colors-base-800)' },
}}
/>
<AnimatePresence>
{isValidDrop(droppableData, active) && (
<IAIDropOverlay isOver={isOver} />
)}
</AnimatePresence>
</Flex>
<Flex
sx={{
h: 'full',
alignItems: 'center',
color: isSelected
? mode('base.900', 'base.50')(colorMode)
: mode('base.700', 'base.200')(colorMode),
fontWeight: isSelected ? 600 : undefined,
fontSize: 'xs',
}}
>
All Images
</Flex>
</Flex>
); );
}; };

View File

@ -0,0 +1,42 @@
import { createSelector } from '@reduxjs/toolkit';
import { AddToBatchDropData } from 'app/components/ImageDnd/typesafeDnd';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { useCallback } from 'react';
import { FaLayerGroup } from 'react-icons/fa';
import { useDispatch } from 'react-redux';
import GenericBoard from './GenericBoard';
const selector = createSelector(stateSelector, (state) => {
return {
count: state.batch.imageNames.length,
};
});
const BatchBoard = ({ isSelected }: { isSelected: boolean }) => {
const dispatch = useDispatch();
const { count } = useAppSelector(selector);
const handleBatchBoardClick = useCallback(() => {
dispatch(boardIdSelected('batch'));
}, [dispatch]);
const droppableData: AddToBatchDropData = {
id: 'batch-board',
actionType: 'ADD_TO_BATCH',
};
return (
<GenericBoard
droppableData={droppableData}
onClick={handleBatchBoardClick}
isSelected={isSelected}
icon={FaLayerGroup}
label="Batch"
badgeCount={count}
/>
);
};
export default BatchBoard;

View File

@ -1,3 +1,4 @@
import { CloseIcon } from '@chakra-ui/icons';
import { import {
Collapse, Collapse,
Flex, Flex,
@ -9,17 +10,17 @@ import {
InputRightElement, InputRightElement,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, 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 { setBoardSearchText } from 'features/gallery/store/boardSlice'; import { setBoardSearchText } from 'features/gallery/store/boardSlice';
import { memo, useState } from 'react';
import HoverableBoard from './HoverableBoard';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import { memo, useState } from 'react';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import AddBoardButton from './AddBoardButton'; import AddBoardButton from './AddBoardButton';
import AllImagesBoard from './AllImagesBoard'; import AllImagesBoard from './AllImagesBoard';
import { CloseIcon } from '@chakra-ui/icons'; import BatchBoard from './BatchBoard';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; import GalleryBoard from './GalleryBoard';
import { stateSelector } from 'app/store/store';
const selector = createSelector( const selector = createSelector(
[stateSelector], [stateSelector],
@ -115,14 +116,19 @@ const BoardsList = (props: Props) => {
}} }}
> >
{!searchMode && ( {!searchMode && (
<GridItem sx={{ p: 1.5 }}> <>
<AllImagesBoard isSelected={!selectedBoardId} /> <GridItem sx={{ p: 1.5 }}>
</GridItem> <AllImagesBoard isSelected={selectedBoardId === 'all'} />
</GridItem>
<GridItem sx={{ p: 1.5 }}>
<BatchBoard isSelected={selectedBoardId === 'batch'} />
</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 }}>
<HoverableBoard <GalleryBoard
board={board} board={board}
isSelected={selectedBoardId === board.board_id} isSelected={selectedBoardId === board.board_id}
/> />

View File

@ -12,35 +12,31 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks'; import { useAppDispatch } from 'app/store/storeHooks';
import { memo, useCallback, useContext } from 'react';
import { FaFolder, FaTrash } from 'react-icons/fa';
import { ContextMenu } from 'chakra-ui-contextmenu'; import { ContextMenu } from 'chakra-ui-contextmenu';
import { BoardDTO } from 'services/api/types';
import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { boardIdSelected } from 'features/gallery/store/gallerySlice'; import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useContext, useMemo } from 'react';
import { FaFolder, FaImages, FaTrash } from 'react-icons/fa';
import { import {
useDeleteBoardMutation, useDeleteBoardMutation,
useUpdateBoardMutation, useUpdateBoardMutation,
} from 'services/api/endpoints/boards'; } from 'services/api/endpoints/boards';
import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { BoardDTO } from 'services/api/types';
import { skipToken } from '@reduxjs/toolkit/dist/query'; import { skipToken } from '@reduxjs/toolkit/dist/query';
import { AnimatePresence } from 'framer-motion'; import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
import IAIDropOverlay from 'common/components/IAIDropOverlay'; import { boardAddedToBatch } from 'app/store/middleware/listenerMiddleware/listeners/addBoardToBatch';
import { DeleteBoardImagesContext } from '../../../../app/contexts/DeleteBoardImagesContext'; import IAIDroppable from 'common/components/IAIDroppable';
import { mode } from 'theme/util/mode'; import { mode } from 'theme/util/mode';
import { import { DeleteBoardImagesContext } from '../../../../app/contexts/DeleteBoardImagesContext';
MoveBoardDropData,
isValidDrop,
useDroppable,
} from 'app/components/ImageDnd/typesafeDnd';
interface HoverableBoardProps { interface GalleryBoardProps {
board: BoardDTO; board: BoardDTO;
isSelected: boolean; isSelected: boolean;
} }
const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => { const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { currentData: coverImage } = useGetImageDTOQuery( const { currentData: coverImage } = useGetImageDTOQuery(
@ -71,21 +67,23 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
deleteBoard(board_id); deleteBoard(board_id);
}, [board_id, deleteBoard]); }, [board_id, deleteBoard]);
const handleAddBoardToBatch = useCallback(() => {
dispatch(boardAddedToBatch({ board_id }));
}, [board_id, dispatch]);
const handleDeleteBoardAndImages = useCallback(() => { const handleDeleteBoardAndImages = useCallback(() => {
console.log({ board }); console.log({ board });
onClickDeleteBoardImages(board); onClickDeleteBoardImages(board);
}, [board, onClickDeleteBoardImages]); }, [board, onClickDeleteBoardImages]);
const droppableData: MoveBoardDropData = { const droppableData: MoveBoardDropData = useMemo(
id: board_id, () => ({
actionType: 'MOVE_BOARD', id: board_id,
context: { boardId: board_id }, actionType: 'MOVE_BOARD',
}; context: { boardId: board_id },
}),
const { isOver, setNodeRef, active } = useDroppable({ [board_id]
id: `board_droppable_${board_id}`, );
data: droppableData,
});
return ( return (
<Box sx={{ touchAction: 'none', height: 'full' }}> <Box sx={{ touchAction: 'none', height: 'full' }}>
@ -94,16 +92,25 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
renderMenu={() => ( renderMenu={() => (
<MenuList sx={{ visibility: 'visible !important' }}> <MenuList sx={{ visibility: 'visible !important' }}>
{board.image_count > 0 && ( {board.image_count > 0 && (
<MenuItem <>
sx={{ color: 'error.300' }} <MenuItem
icon={<FaTrash />} isDisabled={!board.image_count}
onClickCapture={handleDeleteBoardAndImages} icon={<FaImages />}
> onClickCapture={handleAddBoardToBatch}
Delete Board and Images >
</MenuItem> Add Board to Batch
</MenuItem>
<MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />}
onClickCapture={handleDeleteBoardAndImages}
>
Delete Board and Images
</MenuItem>
</>
)} )}
<MenuItem <MenuItem
sx={{ color: mode('error.700', 'error.300')(colorMode) }} sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />} icon={<FaTrash />}
onClickCapture={handleDeleteBoard} onClickCapture={handleDeleteBoard}
> >
@ -127,7 +134,6 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
}} }}
> >
<Flex <Flex
ref={setNodeRef}
onClick={handleSelectBoard} onClick={handleSelectBoard}
sx={{ sx={{
position: 'relative', position: 'relative',
@ -167,11 +173,7 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
> >
<Badge variant="solid">{board.image_count}</Badge> <Badge variant="solid">{board.image_count}</Badge>
</Flex> </Flex>
<AnimatePresence> <IAIDroppable data={droppableData} />
{isValidDrop(droppableData, active) && (
<IAIDropOverlay isOver={isOver} />
)}
</AnimatePresence>
</Flex> </Flex>
<Flex <Flex
@ -219,6 +221,6 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
); );
}); });
HoverableBoard.displayName = 'HoverableBoard'; GalleryBoard.displayName = 'HoverableBoard';
export default HoverableBoard; export default GalleryBoard;

View File

@ -0,0 +1,83 @@
import { As, Badge, Flex } from '@chakra-ui/react';
import { TypesafeDroppableData } from 'app/components/ImageDnd/typesafeDnd';
import IAIDroppable from 'common/components/IAIDroppable';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
type GenericBoardProps = {
droppableData: TypesafeDroppableData;
onClick: () => void;
isSelected: boolean;
icon: As;
label: string;
badgeCount?: number;
};
const GenericBoard = (props: GenericBoardProps) => {
const { droppableData, onClick, isSelected, icon, label, badgeCount } = props;
return (
<Flex
sx={{
flexDir: 'column',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
w: 'full',
h: 'full',
borderRadius: 'base',
}}
>
<Flex
onClick={onClick}
sx={{
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
borderRadius: 'base',
w: 'full',
aspectRatio: '1/1',
overflow: 'hidden',
shadow: isSelected ? 'selected.light' : undefined,
_dark: { shadow: isSelected ? 'selected.dark' : undefined },
flexShrink: 0,
}}
>
<IAINoContentFallback
boxSize={8}
icon={icon}
sx={{
border: '2px solid var(--invokeai-colors-base-200)',
_dark: { border: '2px solid var(--invokeai-colors-base-800)' },
}}
/>
<Flex
sx={{
position: 'absolute',
insetInlineEnd: 0,
top: 0,
p: 1,
}}
>
{badgeCount !== undefined && (
<Badge variant="solid">{badgeCount}</Badge>
)}
</Flex>
<IAIDroppable data={droppableData} />
</Flex>
<Flex
sx={{
h: 'full',
alignItems: 'center',
fontWeight: isSelected ? 600 : undefined,
fontSize: 'xs',
color: isSelected ? 'base.900' : 'base.700',
_dark: { color: isSelected ? 'base.50' : 'base.200' },
}}
>
{label}
</Flex>
</Flex>
);
};
export default GenericBoard;

View File

@ -8,7 +8,7 @@ import {
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import IAIDndImage from 'common/components/IAIDndImage'; import IAIDndImage from 'common/components/IAIDndImage';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySlice'; import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useGetImageDTOQuery } from 'services/api/endpoints/images';

View File

@ -1,4 +1,4 @@
import { Box } from '@chakra-ui/react'; import { Box, Spinner } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd'; import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
@ -7,9 +7,7 @@ import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIDndImage from 'common/components/IAIDndImage'; import IAIDndImage from 'common/components/IAIDndImage';
import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice'; import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice';
import { MouseEvent, memo, useCallback, useMemo } from 'react'; import { MouseEvent, memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { FaTrash } from 'react-icons/fa';
import { ImageDTO } from 'services/api/types';
import { import {
imageRangeEndSelected, imageRangeEndSelected,
imageSelected, imageSelected,
@ -20,50 +18,38 @@ import ImageContextMenu from './ImageContextMenu';
export const makeSelector = (image_name: string) => export const makeSelector = (image_name: string) =>
createSelector( createSelector(
[stateSelector], [stateSelector],
({ gallery }) => { ({ gallery }) => ({
const isSelected = gallery.selection.includes(image_name); isSelected: gallery.selection.includes(image_name),
const selectionCount = gallery.selection.length; selectionCount: gallery.selection.length,
selection: gallery.selection,
return { }),
isSelected,
selectionCount,
};
},
defaultSelectorOptions defaultSelectorOptions
); );
interface HoverableImageProps { interface HoverableImageProps {
imageDTO: ImageDTO; imageName: string;
} }
/**
* Gallery image component with delete/use all/use seed buttons on hover.
*/
const GalleryImage = (props: HoverableImageProps) => { const GalleryImage = (props: HoverableImageProps) => {
const { imageDTO } = props;
const { image_url, thumbnail_url, image_name } = imageDTO;
const localSelector = useMemo(() => makeSelector(image_name), [image_name]);
const { isSelected, selectionCount } = useAppSelector(localSelector);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { imageName } = props;
const { currentData: imageDTO } = useGetImageDTOQuery(imageName);
const localSelector = useMemo(() => makeSelector(imageName), [imageName]);
const { t } = useTranslation(); const { isSelected, selectionCount, selection } =
useAppSelector(localSelector);
const handleClick = useCallback( const handleClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => { (e: MouseEvent<HTMLDivElement>) => {
// multiselect disabled for now if (e.shiftKey) {
// if (e.shiftKey) { dispatch(imageRangeEndSelected(imageName));
// dispatch(imageRangeEndSelected(props.imageDTO.image_name)); } else if (e.ctrlKey || e.metaKey) {
// } else if (e.ctrlKey || e.metaKey) { dispatch(imageSelectionToggled(imageName));
// dispatch(imageSelectionToggled(props.imageDTO.image_name)); } else {
// } else { dispatch(imageSelected(imageName));
// dispatch(imageSelected(props.imageDTO.image_name)); }
// }
dispatch(imageSelected(props.imageDTO.image_name));
}, },
[dispatch, props.imageDTO.image_name] [dispatch, imageName]
); );
const handleDelete = useCallback( const handleDelete = useCallback(
@ -81,7 +67,8 @@ const GalleryImage = (props: HoverableImageProps) => {
if (selectionCount > 1) { if (selectionCount > 1) {
return { return {
id: 'gallery-image', id: 'gallery-image',
payloadType: 'GALLERY_SELECTION', payloadType: 'IMAGE_NAMES',
payload: { image_names: selection },
}; };
} }
@ -92,15 +79,19 @@ const GalleryImage = (props: HoverableImageProps) => {
payload: { imageDTO }, payload: { imageDTO },
}; };
} }
}, [imageDTO, selectionCount]); }, [imageDTO, selection, selectionCount]);
if (!imageDTO) {
return <Spinner />;
}
return ( return (
<Box sx={{ w: 'full', h: 'full', touchAction: 'none' }}> <Box sx={{ w: 'full', h: 'full', touchAction: 'none' }}>
<ImageContextMenu image={imageDTO}> <ImageContextMenu imageDTO={imageDTO}>
{(ref) => ( {(ref) => (
<Box <Box
position="relative" position="relative"
key={image_name} key={imageName}
userSelect="none" userSelect="none"
ref={ref} ref={ref}
sx={{ sx={{
@ -117,13 +108,13 @@ const GalleryImage = (props: HoverableImageProps) => {
isSelected={isSelected} isSelected={isSelected}
minSize={0} minSize={0}
onClickReset={handleDelete} onClickReset={handleDelete}
resetIcon={<FaTrash />}
resetTooltip="Delete image"
imageSx={{ w: 'full', h: 'full' }} imageSx={{ w: 'full', h: 'full' }}
// withResetIcon // removed bc it's too easy to accidentally delete images
isDropDisabled={true} isDropDisabled={true}
isUploadDisabled={true} isUploadDisabled={true}
thumbnail={true} thumbnail={true}
// resetIcon={<FaTrash />}
// resetTooltip="Delete image"
// withResetIcon // removed bc it's too easy to accidentally delete images
/> />
</Box> </Box>
)} )}

View File

@ -7,8 +7,9 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu'; import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu';
import { import {
imageAddedToBatch,
imageRemovedFromBatch,
imagesAddedToBatch, imagesAddedToBatch,
selectionAddedToBatch,
} from 'features/batch/store/batchSlice'; } from 'features/batch/store/batchSlice';
import { import {
resizeAndScaleCanvas, resizeAndScaleCanvas,
@ -21,34 +22,46 @@ import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { setActiveTab } from 'features/ui/store/uiSlice'; import { setActiveTab } from 'features/ui/store/uiSlice';
import { memo, useCallback, useContext, useMemo } from 'react'; import { memo, useCallback, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FaExpand, FaFolder, FaShare, FaTrash } from 'react-icons/fa'; import {
FaExpand,
FaFolder,
FaLayerGroup,
FaShare,
FaTrash,
} from 'react-icons/fa';
import { IoArrowUndoCircleOutline } from 'react-icons/io5'; import { IoArrowUndoCircleOutline } from 'react-icons/io5';
import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages'; import {
useAddManyBoardImagesMutation,
useDeleteBoardImageMutation,
useDeleteManyBoardImagesMutation,
} from 'services/api/endpoints/boardImages';
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';
type Props = { type Props = {
image: ImageDTO; imageDTO: ImageDTO;
children: ContextMenuProps<HTMLDivElement>['children']; children: ContextMenuProps<HTMLDivElement>['children'];
}; };
const ImageContextMenu = ({ image, children }: Props) => { const ImageContextMenu = ({ imageDTO, children }: Props) => {
const selector = useMemo( const selector = useMemo(
() => () =>
createSelector( createSelector(
[stateSelector], [stateSelector],
({ gallery, batch }) => { ({ gallery, batch }) => {
const selectionCount = gallery.selection.length; const isBatch = gallery.selectedBoardId === 'batch';
const isInBatch = batch.imageNames.includes(image.image_name);
return { selectionCount, isInBatch }; const selection = isBatch ? batch.selection : gallery.selection;
const isInBatch = batch.imageNames.includes(imageDTO.image_name);
return { selection, isInBatch };
}, },
defaultSelectorOptions defaultSelectorOptions
), ),
[image.image_name] [imageDTO.image_name]
); );
const { selectionCount, isInBatch } = useAppSelector(selector); const { selection, isInBatch } = useAppSelector(selector);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { t } = useTranslation(); const { t } = useTranslation();
@ -60,37 +73,39 @@ const ImageContextMenu = ({ image, children }: Props) => {
const { onClickAddToBoard } = useContext(AddImageToBoardContext); const { onClickAddToBoard } = useContext(AddImageToBoardContext);
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
if (!image) { if (!imageDTO) {
return; return;
} }
dispatch(imageToDeleteSelected(image)); dispatch(imageToDeleteSelected(imageDTO));
}, [dispatch, image]); }, [dispatch, imageDTO]);
const { recallBothPrompts, recallSeed, recallAllParameters } = const { recallBothPrompts, recallSeed, recallAllParameters } =
useRecallParameters(); useRecallParameters();
const [removeFromBoard] = useRemoveImageFromBoardMutation(); const [deleteBoardImage] = useDeleteBoardImageMutation();
const [deleteManyBoardImages] = useDeleteManyBoardImagesMutation();
const [addManyBoardImages] = useAddManyBoardImagesMutation();
// Recall parameters handlers // Recall parameters handlers
const handleRecallPrompt = useCallback(() => { const handleRecallPrompt = useCallback(() => {
recallBothPrompts( recallBothPrompts(
image.metadata?.positive_conditioning, imageDTO.metadata?.positive_conditioning,
image.metadata?.negative_conditioning imageDTO.metadata?.negative_conditioning
); );
}, [ }, [
image.metadata?.negative_conditioning, imageDTO.metadata?.negative_conditioning,
image.metadata?.positive_conditioning, imageDTO.metadata?.positive_conditioning,
recallBothPrompts, recallBothPrompts,
]); ]);
const handleRecallSeed = useCallback(() => { const handleRecallSeed = useCallback(() => {
recallSeed(image.metadata?.seed); recallSeed(imageDTO.metadata?.seed);
}, [image, recallSeed]); }, [imageDTO, recallSeed]);
const handleSendToImageToImage = useCallback(() => { const handleSendToImageToImage = useCallback(() => {
dispatch(sentImageToImg2Img()); dispatch(sentImageToImg2Img());
dispatch(initialImageSelected(image)); dispatch(initialImageSelected(imageDTO));
}, [dispatch, image]); }, [dispatch, imageDTO]);
// const handleRecallInitialImage = useCallback(() => { // const handleRecallInitialImage = useCallback(() => {
// recallInitialImage(image.metadata.invokeai?.node?.image); // recallInitialImage(image.metadata.invokeai?.node?.image);
@ -98,7 +113,7 @@ const ImageContextMenu = ({ image, children }: Props) => {
const handleSendToCanvas = () => { const handleSendToCanvas = () => {
dispatch(sentImageToCanvas()); dispatch(sentImageToCanvas());
dispatch(setInitialCanvasImage(image)); dispatch(setInitialCanvasImage(imageDTO));
dispatch(resizeAndScaleCanvas()); dispatch(resizeAndScaleCanvas());
dispatch(setActiveTab('unifiedCanvas')); dispatch(setActiveTab('unifiedCanvas'));
@ -111,8 +126,8 @@ const ImageContextMenu = ({ image, children }: Props) => {
}; };
const handleUseAllParameters = useCallback(() => { const handleUseAllParameters = useCallback(() => {
recallAllParameters(image); recallAllParameters(imageDTO);
}, [image, recallAllParameters]); }, [imageDTO, recallAllParameters]);
const handleLightBox = () => { const handleLightBox = () => {
// dispatch(setCurrentImage(image)); // dispatch(setCurrentImage(image));
@ -120,34 +135,50 @@ const ImageContextMenu = ({ image, children }: Props) => {
}; };
const handleAddToBoard = useCallback(() => { const handleAddToBoard = useCallback(() => {
onClickAddToBoard(image); onClickAddToBoard(imageDTO);
}, [image, onClickAddToBoard]); }, [imageDTO, onClickAddToBoard]);
const handleRemoveFromBoard = useCallback(() => { const handleRemoveFromBoard = useCallback(() => {
if (!image.board_id) { if (!imageDTO.board_id) {
return; return;
} }
removeFromBoard({ board_id: image.board_id, image_name: image.image_name }); deleteBoardImage({ image_name: imageDTO.image_name });
}, [image.board_id, image.image_name, removeFromBoard]); }, [deleteBoardImage, imageDTO.board_id, imageDTO.image_name]);
const handleOpenInNewTab = () => { const handleAddSelectionToBoard = useCallback(() => {
window.open(image.image_url, '_blank'); // addManyBoardImages({ board_id, image_names: selection });
}; }, []);
const handleRemoveSelectionFromBoard = useCallback(() => {
deleteManyBoardImages({ image_names: selection });
}, [deleteManyBoardImages, selection]);
const handleOpenInNewTab = useCallback(() => {
window.open(imageDTO.image_url, '_blank');
}, [imageDTO.image_url]);
const handleAddSelectionToBatch = useCallback(() => { const handleAddSelectionToBatch = useCallback(() => {
dispatch(selectionAddedToBatch()); dispatch(imagesAddedToBatch(selection));
}, [dispatch]); }, [dispatch, selection]);
const handleAddToBatch = useCallback(() => { const handleAddToBatch = useCallback(() => {
dispatch(imagesAddedToBatch([image.image_name])); dispatch(imageAddedToBatch(imageDTO.image_name));
}, [dispatch, image.image_name]); }, [dispatch, imageDTO]);
const handleRemoveFromBatch = useCallback(() => {
dispatch(imageRemovedFromBatch(imageDTO.image_name));
}, [dispatch, imageDTO]);
if (!imageDTO) {
return null;
}
return ( return (
<ContextMenu<HTMLDivElement> <ContextMenu<HTMLDivElement>
menuProps={{ size: 'sm', isLazy: true }} menuProps={{ size: 'sm', isLazy: true }}
renderMenu={() => ( renderMenu={() => (
<MenuList sx={{ visibility: 'visible !important' }}> <MenuList sx={{ visibility: 'visible !important' }}>
{selectionCount === 1 ? ( {selection.length === 1 ? (
<> <>
<MenuItem <MenuItem
icon={<ExternalLinkIcon />} icon={<ExternalLinkIcon />}
@ -164,7 +195,7 @@ const ImageContextMenu = ({ image, children }: Props) => {
icon={<IoArrowUndoCircleOutline />} icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleRecallPrompt} onClickCapture={handleRecallPrompt}
isDisabled={ isDisabled={
image?.metadata?.positive_conditioning === undefined imageDTO?.metadata?.positive_conditioning === undefined
} }
> >
{t('parameters.usePrompt')} {t('parameters.usePrompt')}
@ -173,24 +204,17 @@ const ImageContextMenu = ({ image, children }: Props) => {
<MenuItem <MenuItem
icon={<IoArrowUndoCircleOutline />} icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleRecallSeed} onClickCapture={handleRecallSeed}
isDisabled={image?.metadata?.seed === undefined} isDisabled={imageDTO?.metadata?.seed === undefined}
> >
{t('parameters.useSeed')} {t('parameters.useSeed')}
</MenuItem> </MenuItem>
{/* <MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleRecallInitialImage}
isDisabled={image?.metadata?.type !== 'img2img'}
>
{t('parameters.useInitImg')}
</MenuItem> */}
<MenuItem <MenuItem
icon={<IoArrowUndoCircleOutline />} icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleUseAllParameters} onClickCapture={handleUseAllParameters}
isDisabled={ isDisabled={
// what should these be // what should these be
!['t2l', 'l2l', 'inpaint'].includes( !['t2l', 'l2l', 'inpaint'].includes(
String(image?.metadata?.type) String(imageDTO?.metadata?.type)
) )
} }
> >
@ -212,17 +236,18 @@ const ImageContextMenu = ({ image, children }: Props) => {
{t('parameters.sendToUnifiedCanvas')} {t('parameters.sendToUnifiedCanvas')}
</MenuItem> </MenuItem>
)} )}
{/* <MenuItem <MenuItem
icon={<FaFolder />} icon={<FaLayerGroup />}
isDisabled={isInBatch} onClickCapture={
onClickCapture={handleAddToBatch} isInBatch ? handleRemoveFromBatch : handleAddToBatch
}
> >
Add to Batch {isInBatch ? 'Remove from Batch' : 'Add to Batch'}
</MenuItem> */}
<MenuItem icon={<FaFolder />} onClickCapture={handleAddToBoard}>
{image.board_id ? 'Change Board' : 'Add to Board'}
</MenuItem> </MenuItem>
{image.board_id && ( <MenuItem icon={<FaFolder />} onClickCapture={handleAddToBoard}>
{imageDTO.board_id ? 'Change Board' : 'Add to Board'}
</MenuItem>
{imageDTO.board_id && (
<MenuItem <MenuItem
icon={<FaFolder />} icon={<FaFolder />}
onClickCapture={handleRemoveFromBoard} onClickCapture={handleRemoveFromBoard}
@ -241,18 +266,23 @@ const ImageContextMenu = ({ image, children }: Props) => {
) : ( ) : (
<> <>
<MenuItem <MenuItem
isDisabled={true}
icon={<FaFolder />} icon={<FaFolder />}
onClickCapture={handleAddToBoard} onClickCapture={handleAddSelectionToBoard}
> >
Move Selection to Board Move Selection to Board
</MenuItem> </MenuItem>
{/* <MenuItem <MenuItem
icon={<FaFolderPlus />} icon={<FaFolder />}
onClickCapture={handleRemoveSelectionFromBoard}
>
Reset Board for Selection
</MenuItem>
<MenuItem
icon={<FaLayerGroup />}
onClickCapture={handleAddSelectionToBatch} onClickCapture={handleAddSelectionToBatch}
> >
Add Selection to Batch Add Selection to Batch
</MenuItem> */} </MenuItem>
<MenuItem <MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }} sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />} icon={<FaTrash />}

View File

@ -19,7 +19,7 @@ import {
} 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 { ChangeEvent, memo, useCallback, useRef } from 'react'; import { ChangeEvent, memo, useCallback, useMemo, useRef } 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 { FaImage, FaServer, FaWrench } from 'react-icons/fa';
@ -29,14 +29,10 @@ import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { import { shouldAutoSwitchChanged } from 'features/gallery/store/gallerySlice';
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
imageCategoriesChanged,
shouldAutoSwitchChanged,
} from 'features/gallery/store/gallerySlice';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { mode } from 'theme/util/mode'; import { mode } from 'theme/util/mode';
import BatchGrid from './BatchGrid';
import BoardsList from './Boards/BoardsList'; import BoardsList from './Boards/BoardsList';
import ImageGalleryGrid from './ImageGalleryGrid'; import ImageGalleryGrid from './ImageGalleryGrid';
@ -66,6 +62,7 @@ const ImageGalleryContent = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { t } = useTranslation(); const { t } = useTranslation();
const resizeObserverRef = useRef<HTMLDivElement>(null); const resizeObserverRef = useRef<HTMLDivElement>(null);
const galleryGridRef = useRef<HTMLDivElement>(null);
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
@ -83,6 +80,16 @@ const ImageGalleryContent = () => {
}), }),
}); });
const boardTitle = useMemo(() => {
if (selectedBoardId === 'batch') {
return 'Batch';
}
if (selectedBoard) {
return selectedBoard.board_name;
}
return 'All Images';
}, [selectedBoard, selectedBoardId]);
const { isOpen: isBoardListOpen, onToggle } = useDisclosure(); const { isOpen: isBoardListOpen, onToggle } = useDisclosure();
const handleChangeGalleryImageMinimumWidth = (v: number) => { const handleChangeGalleryImageMinimumWidth = (v: number) => {
@ -95,12 +102,10 @@ const ImageGalleryContent = () => {
}; };
const handleClickImagesCategory = useCallback(() => { const handleClickImagesCategory = useCallback(() => {
dispatch(imageCategoriesChanged(IMAGE_CATEGORIES));
dispatch(setGalleryView('images')); dispatch(setGalleryView('images'));
}, [dispatch]); }, [dispatch]);
const handleClickAssetsCategory = useCallback(() => { const handleClickAssetsCategory = useCallback(() => {
dispatch(imageCategoriesChanged(ASSETS_CATEGORIES));
dispatch(setGalleryView('assets')); dispatch(setGalleryView('assets'));
}, [dispatch]); }, [dispatch]);
@ -163,7 +168,7 @@ const ImageGalleryContent = () => {
fontWeight: 600, fontWeight: 600,
}} }}
> >
{selectedBoard ? selectedBoard.board_name : 'All Images'} {boardTitle}
</Text> </Text>
<ChevronUpIcon <ChevronUpIcon
sx={{ sx={{
@ -216,8 +221,8 @@ const ImageGalleryContent = () => {
<BoardsList isOpen={isBoardListOpen} /> <BoardsList isOpen={isBoardListOpen} />
</Box> </Box>
</Box> </Box>
<Flex direction="column" gap={2} h="full" w="full"> <Flex ref={galleryGridRef} direction="column" gap={2} h="full" w="full">
<ImageGalleryGrid /> {selectedBoardId === 'batch' ? <BatchGrid /> : <ImageGalleryGrid />}
</Flex> </Flex>
</VStack> </VStack>
); );

View File

@ -1,74 +1,38 @@
import { import { Box } from '@chakra-ui/react';
Box, import { useAppSelector } from 'app/store/storeHooks';
Flex,
FlexProps,
Grid,
Skeleton,
Spinner,
forwardRef,
} from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton'; import IAIButton from 'common/components/IAIButton';
import { IMAGE_LIMIT } from 'features/gallery/store/gallerySlice';
import { useOverlayScrollbars } from 'overlayscrollbars-react'; import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
PropsWithChildren,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FaImage } from 'react-icons/fa'; import { FaImage } from 'react-icons/fa';
import GalleryImage from './GalleryImage'; import GalleryImage from './GalleryImage';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { RootState, stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { selectFilteredImages } from 'features/gallery/store/gallerySlice';
import { VirtuosoGrid } from 'react-virtuoso'; import { VirtuosoGrid } from 'react-virtuoso';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; import { useLoadMoreImages } from '../hooks/useLoadMoreImages';
import { receivedPageOfImages } from 'services/api/thunks/image'; import ItemContainer from './ItemContainer';
import { ImageDTO } from 'services/api/types'; import ListContainer from './ListContainer';
const selector = createSelector( const selector = createSelector(
[stateSelector, selectFilteredImages], [stateSelector],
(state, filteredImages) => { (state) => {
const { const { galleryImageMinimumWidth } = state.gallery;
categories,
total: allImagesTotal,
isLoading,
isFetching,
selectedBoardId,
} = state.gallery;
let images = filteredImages as (ImageDTO | 'loading')[];
if (!isLoading && isFetching) {
// loading, not not the initial load
images = images.concat(Array(IMAGE_LIMIT).fill('loading'));
}
return { return {
images, galleryImageMinimumWidth,
allImagesTotal,
isLoading,
isFetching,
categories,
selectedBoardId,
}; };
}, },
defaultSelectorOptions defaultSelectorOptions
); );
const ImageGalleryGrid = () => { const ImageGalleryGrid = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation(); const { t } = useTranslation();
const rootRef = useRef(null); const rootRef = useRef<HTMLDivElement>(null);
const emptyGalleryRef = useRef<HTMLDivElement>(null);
const [scroller, setScroller] = useState<HTMLElement | null>(null); const [scroller, setScroller] = useState<HTMLElement | null>(null);
const [initialize, osInstance] = useOverlayScrollbars({ const [initialize, osInstance] = useOverlayScrollbars({
defer: true, defer: true,
@ -83,46 +47,27 @@ const ImageGalleryGrid = () => {
}, },
}); });
const { galleryImageMinimumWidth } = useAppSelector(selector);
const { const {
images, imageNames,
isLoading, galleryView,
isFetching, loadMoreImages,
allImagesTotal,
categories,
selectedBoardId, selectedBoardId,
} = useAppSelector(selector); status,
areMoreAvailable,
const { selectedBoard } = useListAllBoardsQuery(undefined, { } = useLoadMoreImages();
selectFromResult: ({ data }) => ({
selectedBoard: data?.find((b) => b.board_id === selectedBoardId),
}),
});
const filteredImagesTotal = useMemo(
() => selectedBoard?.image_count ?? allImagesTotal,
[allImagesTotal, selectedBoard?.image_count]
);
const areMoreAvailable = useMemo(() => {
return images.length < filteredImagesTotal;
}, [images.length, filteredImagesTotal]);
const handleLoadMoreImages = useCallback(() => { const handleLoadMoreImages = useCallback(() => {
dispatch( loadMoreImages({});
receivedPageOfImages({ }, [loadMoreImages]);
categories,
board_id: selectedBoardId,
is_intermediate: false,
})
);
}, [categories, dispatch, selectedBoardId]);
const handleEndReached = useMemo(() => { const handleEndReached = useMemo(() => {
if (areMoreAvailable && !isLoading) { if (areMoreAvailable && status !== 'pending') {
return handleLoadMoreImages; return handleLoadMoreImages;
} }
return undefined; return undefined;
}, [areMoreAvailable, handleLoadMoreImages, isLoading]); }, [areMoreAvailable, handleLoadMoreImages, status]);
useEffect(() => { useEffect(() => {
const { current: root } = rootRef; const { current: root } = rootRef;
@ -137,53 +82,68 @@ const ImageGalleryGrid = () => {
return () => osInstance()?.destroy(); return () => osInstance()?.destroy();
}, [scroller, initialize, osInstance]); }, [scroller, initialize, osInstance]);
if (isLoading) { useEffect(() => {
// TODO: this doesn't actually prevent 2 intial image loads...
if (status !== undefined) {
return;
}
// rough, conservative calculation of how many images fit in the gallery
// TODO: this gets an incorrect value on first load...
const galleryHeight = rootRef.current?.clientHeight ?? 0;
const galleryWidth = rootRef.current?.clientHeight ?? 0;
const rows = galleryHeight / galleryImageMinimumWidth;
const columns = galleryWidth / galleryImageMinimumWidth;
const imagesToLoad = Math.ceil(rows * columns);
// load up that many images
loadMoreImages({
offset: 0,
limit: imagesToLoad,
});
}, [
galleryImageMinimumWidth,
galleryView,
loadMoreImages,
selectedBoardId,
status,
]);
if (status === 'fulfilled' && imageNames.length === 0) {
return ( return (
<Flex <Box ref={emptyGalleryRef} sx={{ w: 'full', h: 'full' }}>
sx={{ <IAINoContentFallback
w: 'full', label={t('gallery.noImagesInGallery')}
h: 'full', icon={FaImage}
alignItems: 'center',
justifyContent: 'center',
}}
>
<Spinner
size="xl"
sx={{ color: 'base.300', _dark: { color: 'base.700' } }}
/> />
</Flex> </Box>
); );
} }
if (images.length) { if (status !== 'rejected') {
return ( return (
<> <>
<Box ref={rootRef} data-overlayscrollbars="" h="100%"> <Box ref={rootRef} data-overlayscrollbars="" h="100%">
<VirtuosoGrid <VirtuosoGrid
style={{ height: '100%' }} style={{ height: '100%' }}
data={images} data={imageNames}
endReached={handleEndReached} endReached={handleEndReached}
components={{ components={{
Item: ItemContainer, Item: ItemContainer,
List: ListContainer, List: ListContainer,
}} }}
scrollerRef={setScroller} scrollerRef={setScroller}
itemContent={(index, item) => itemContent={(index, imageName) => (
typeof item === 'string' ? ( <GalleryImage key={imageName} imageName={imageName} />
<Skeleton sx={{ w: 'full', h: 'full', aspectRatio: '1/1' }} /> )}
) : (
<GalleryImage
key={`${item.image_name}-${item.thumbnail_url}`}
imageDTO={item}
/>
)
}
/> />
</Box> </Box>
<IAIButton <IAIButton
onClick={handleLoadMoreImages} onClick={handleLoadMoreImages}
isDisabled={!areMoreAvailable} isDisabled={!areMoreAvailable}
isLoading={isFetching} isLoading={status === 'pending'}
loadingText="Loading" loadingText="Loading"
flexShrink={0} flexShrink={0}
> >
@ -194,40 +154,6 @@ const ImageGalleryGrid = () => {
</> </>
); );
} }
return (
<IAINoContentFallback
label={t('gallery.noImagesInGallery')}
icon={FaImage}
/>
);
}; };
type ItemContainerProps = PropsWithChildren & FlexProps;
const ItemContainer = forwardRef((props: ItemContainerProps, ref) => (
<Box className="item-container" ref={ref} p={1.5}>
{props.children}
</Box>
));
type ListContainerProps = PropsWithChildren & FlexProps;
const ListContainer = forwardRef((props: ListContainerProps, ref) => {
const galleryImageMinimumWidth = useAppSelector(
(state: RootState) => state.gallery.galleryImageMinimumWidth
);
return (
<Grid
{...props}
className="list-container"
ref={ref}
sx={{
gridTemplateColumns: `repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr));`,
}}
>
{props.children}
</Grid>
);
});
export default memo(ImageGalleryGrid); export default memo(ImageGalleryGrid);

View File

@ -0,0 +1,11 @@
import { Box, FlexProps, forwardRef } from '@chakra-ui/react';
import { PropsWithChildren } from 'react';
type ItemContainerProps = PropsWithChildren & FlexProps;
const ItemContainer = forwardRef((props: ItemContainerProps, ref) => (
<Box className="item-container" ref={ref} p={1.5}>
{props.children}
</Box>
));
export default ItemContainer;

View File

@ -0,0 +1,26 @@
import { FlexProps, Grid, forwardRef } from '@chakra-ui/react';
import { RootState } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { PropsWithChildren } from 'react';
type ListContainerProps = PropsWithChildren & FlexProps;
const ListContainer = forwardRef((props: ListContainerProps, ref) => {
const galleryImageMinimumWidth = useAppSelector(
(state: RootState) => state.gallery.galleryImageMinimumWidth
);
return (
<Grid
{...props}
className="list-container"
ref={ref}
sx={{
gridTemplateColumns: `repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr));`,
}}
>
{props.children}
</Grid>
);
});
export default ListContainer;

View File

@ -4,7 +4,6 @@ import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { import {
imageSelected, imageSelected,
selectFilteredImages,
selectImagesById, selectImagesById,
} from 'features/gallery/store/gallerySlice'; } from 'features/gallery/store/gallerySlice';
import { clamp, isEqual } from 'lodash-es'; import { clamp, isEqual } from 'lodash-es';
@ -13,6 +12,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FaAngleDoubleRight, FaAngleLeft, FaAngleRight } from 'react-icons/fa'; import { FaAngleDoubleRight, FaAngleLeft, FaAngleRight } from 'react-icons/fa';
import { receivedPageOfImages } from 'services/api/thunks/image'; import { receivedPageOfImages } from 'services/api/thunks/image';
import { selectFilteredImages } from '../store/gallerySelectors';
const nextPrevButtonTriggerAreaStyles: ChakraProps['sx'] = { const nextPrevButtonTriggerAreaStyles: ChakraProps['sx'] = {
height: '100%', height: '100%',

View File

@ -0,0 +1,64 @@
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { useCallback } from 'react';
import { ImagesLoadedArg, imagesLoaded } from 'services/api/thunks/image';
const selector = createSelector(
[stateSelector],
(state) => {
const { selectedBoardId, galleryView } = state.gallery;
const imageNames =
state.gallery.imageNamesByIdAndView[selectedBoardId]?.[galleryView]
.imageNames ?? [];
const total =
state.gallery.imageNamesByIdAndView[selectedBoardId]?.[galleryView]
.total ?? 0;
const status =
state.gallery.statusByIdAndView[selectedBoardId]?.[galleryView] ??
undefined;
return {
imageNames,
status,
total,
selectedBoardId,
galleryView,
};
},
defaultSelectorOptions
);
export const useLoadMoreImages = () => {
const dispatch = useAppDispatch();
const { selectedBoardId, imageNames, galleryView, total, status } =
useAppSelector(selector);
const loadMoreImages = useCallback(
(arg: Partial<ImagesLoadedArg>) => {
dispatch(
imagesLoaded({
board_id: selectedBoardId,
offset: imageNames.length,
view: galleryView,
...arg,
})
);
},
[dispatch, galleryView, imageNames.length, selectedBoardId]
);
return {
loadMoreImages,
selectedBoardId,
imageNames,
galleryView,
areMoreAvailable: imageNames.length < total,
total,
status,
};
};

View File

@ -15,4 +15,6 @@ export const galleryPersistDenylist: (keyof typeof initialGalleryState)[] = [
'galleryView', 'galleryView',
'total', 'total',
'isInitialized', 'isInitialized',
'imageNamesByIdAndView',
'statusByIdAndView',
]; ];

View File

@ -1,3 +1,61 @@
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 { keyBy } from 'lodash-es';
import { galleryImagesAdapter, initialGalleryState } from './gallerySlice';
export const gallerySelector = (state: RootState) => state.gallery; export const gallerySelector = (state: RootState) => state.gallery;
export const selectFilteredImagesLocal = createSelector(
(state: typeof initialGalleryState) => state,
(galleryState) => {
const allImages = galleryImagesAdapter
.getSelectors()
.selectAll(galleryState);
const { categories, selectedBoardId } = galleryState;
const filteredImages = allImages.filter((i) => {
const isInCategory = categories.includes(i.image_category);
const isInSelectedBoard = selectedBoardId
? i.board_id === selectedBoardId
: true;
return isInCategory && isInSelectedBoard;
});
return filteredImages;
}
);
export const selectFilteredImages = createSelector(
(state: RootState) => state,
(state) => {
return selectFilteredImagesLocal(state.gallery);
},
defaultSelectorOptions
);
export const selectFilteredImagesAsObject = createSelector(
selectFilteredImages,
(filteredImages) => keyBy(filteredImages, 'image_name')
);
export const selectFilteredImagesIds = createSelector(
selectFilteredImages,
(filteredImages) => filteredImages.map((i) => i.image_name)
);
export const selectLastSelectedImage = createSelector(
(state: RootState) => state,
(state) => state.gallery.selection[state.gallery.selection.length - 1],
defaultSelectorOptions
);
export const selectSelectedImages = createSelector(
(state: RootState) => state,
(state) =>
galleryImagesAdapter
.getSelectors()
.selectAll(state.gallery)
.filter((i) => state.gallery.selection.includes(i.image_name)),
defaultSelectorOptions
);

View File

@ -1,21 +1,14 @@
import type { PayloadAction, Update } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit';
import { import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
createEntityAdapter,
createSelector,
createSlice,
} from '@reduxjs/toolkit';
import { RootState } from 'app/store/store'; import { RootState } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { dateComparator } from 'common/util/dateComparator'; import { dateComparator } from 'common/util/dateComparator';
import { keyBy, uniq } from 'lodash-es'; import { filter, forEach, uniq } from 'lodash-es';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
import { boardsApi } from 'services/api/endpoints/boards'; import { boardsApi } from 'services/api/endpoints/boards';
import { import { imageDeleted, imagesLoaded } from 'services/api/thunks/image';
imageUrlsReceived,
receivedPageOfImages,
} from 'services/api/thunks/image';
import { ImageCategory, ImageDTO } from 'services/api/types'; import { ImageCategory, ImageDTO } from 'services/api/types';
export const imagesAdapter = createEntityAdapter<ImageDTO>({ export const galleryImagesAdapter = createEntityAdapter<ImageDTO>({
selectId: (image) => image.image_name, selectId: (image) => image.image_name,
sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at), sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at),
}); });
@ -31,23 +24,99 @@ 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;
type AdditionaGalleryState = { type RequestState = 'pending' | 'fulfilled' | 'rejected';
type GalleryView = 'images' | 'assets';
// dirty hack to get autocompletion while still accepting any string
type BoardPath =
| 'all.images'
| 'all.assets'
| 'none.images'
| 'none.assets'
| 'batch.images'
| 'batch.assets'
| `${string}.${GalleryView}`;
const systemBoards = [
'all.images',
'all.assets',
'none.images',
'none.assets',
'batch.images',
'batch.assets',
];
type Boards = Record<
BoardPath,
{
path: BoardPath;
id: 'all' | 'none' | 'batch' | (string & Record<never, never>);
view: GalleryView;
imageNames: string[];
total: number;
status: RequestState | undefined;
}
>;
type AdditionalGalleryState = {
offset: number; offset: number;
limit: number; limit: number;
total: number; total: number;
isLoading: boolean; isLoading: boolean;
isFetching: boolean; isFetching: boolean;
categories: ImageCategory[]; categories: ImageCategory[];
selectedBoardId?: string;
selection: string[]; selection: string[];
shouldAutoSwitch: boolean; shouldAutoSwitch: boolean;
galleryImageMinimumWidth: number; galleryImageMinimumWidth: number;
galleryView: 'images' | 'assets';
isInitialized: boolean; isInitialized: boolean;
galleryView: GalleryView;
selectedBoardId: 'all' | 'none' | 'batch' | (string & Record<never, never>);
boards: Boards;
};
const initialBoardState = { imageNames: [], total: 0, status: undefined };
const initialBoards: Boards = {
'all.images': {
path: 'all.images',
id: 'all',
view: 'images',
...initialBoardState,
},
'all.assets': {
path: 'all.assets',
id: 'all',
view: 'assets',
...initialBoardState,
},
'none.images': {
path: 'none.images',
id: 'none',
view: 'images',
...initialBoardState,
},
'none.assets': {
path: 'none.assets',
id: 'none',
view: 'assets',
...initialBoardState,
},
'batch.images': {
path: 'batch.images',
id: 'batch',
view: 'images',
...initialBoardState,
},
'batch.assets': {
path: 'batch.assets',
id: 'batch',
view: 'assets',
...initialBoardState,
},
}; };
export const initialGalleryState = export const initialGalleryState =
imagesAdapter.getInitialState<AdditionaGalleryState>({ galleryImagesAdapter.getInitialState<AdditionalGalleryState>({
offset: 0, offset: 0,
limit: 0, limit: 0,
total: 0, total: 0,
@ -59,57 +128,45 @@ export const initialGalleryState =
galleryImageMinimumWidth: 96, galleryImageMinimumWidth: 96,
galleryView: 'images', galleryView: 'images',
isInitialized: false, isInitialized: false,
selectedBoardId: 'all',
boards: initialBoards,
}); });
export const gallerySlice = createSlice({ export const gallerySlice = createSlice({
name: 'gallery', name: 'gallery',
initialState: initialGalleryState, initialState: initialGalleryState,
reducers: { reducers: {
imageUpserted: (state, action: PayloadAction<ImageDTO>) => {
imagesAdapter.upsertOne(state, action.payload);
if (
state.shouldAutoSwitch &&
action.payload.image_category === 'general'
) {
state.selection = [action.payload.image_name];
state.galleryView = 'images';
state.categories = IMAGE_CATEGORIES;
}
},
imageUpdatedOne: (state, action: PayloadAction<Update<ImageDTO>>) => {
imagesAdapter.updateOne(state, action.payload);
},
imageRemoved: (state, action: PayloadAction<string>) => { imageRemoved: (state, action: PayloadAction<string>) => {
imagesAdapter.removeOne(state, action.payload); galleryImagesAdapter.removeOne(state, action.payload);
}, },
imagesRemoved: (state, action: PayloadAction<string[]>) => { imagesRemoved: (state, action: PayloadAction<string[]>) => {
imagesAdapter.removeMany(state, action.payload); galleryImagesAdapter.removeMany(state, action.payload);
},
imageCategoriesChanged: (state, action: PayloadAction<ImageCategory[]>) => {
state.categories = action.payload;
}, },
imageRangeEndSelected: (state, action: PayloadAction<string>) => { imageRangeEndSelected: (state, action: PayloadAction<string>) => {
const rangeEndImageName = action.payload; const rangeEndImageName = action.payload;
const lastSelectedImage = state.selection[state.selection.length - 1]; const lastSelectedImage = state.selection[state.selection.length - 1];
const filteredImages = selectFilteredImagesLocal(state); // get image names for the current board and view
const imageNames =
state.boards[`${state.selectedBoardId}.${state.galleryView}`]
.imageNames;
const lastClickedIndex = filteredImages.findIndex( // get the index of the last selected image
(n) => n.image_name === lastSelectedImage const lastClickedIndex = imageNames.findIndex(
(n) => n === lastSelectedImage
); );
const currentClickedIndex = filteredImages.findIndex( // get the index of the just-clicked image
(n) => n.image_name === rangeEndImageName const currentClickedIndex = imageNames.findIndex(
(n) => n === rangeEndImageName
); );
if (lastClickedIndex > -1 && currentClickedIndex > -1) { if (lastClickedIndex > -1 && currentClickedIndex > -1) {
// We have a valid range! // We have a valid range, selected it!
const start = Math.min(lastClickedIndex, currentClickedIndex); const start = Math.min(lastClickedIndex, currentClickedIndex);
const end = Math.max(lastClickedIndex, currentClickedIndex); const end = Math.max(lastClickedIndex, currentClickedIndex);
const imagesToSelect = filteredImages const imagesToSelect = imageNames.slice(start, end + 1);
.slice(start, end + 1)
.map((i) => i.image_name);
state.selection = uniq(state.selection.concat(imagesToSelect)); state.selection = uniq(state.selection.concat(imagesToSelect));
} }
@ -122,9 +179,10 @@ export const gallerySlice = createSlice({
state.selection = state.selection.filter( state.selection = state.selection.filter(
(imageName) => imageName !== action.payload (imageName) => imageName !== action.payload
); );
} else { return;
state.selection = uniq(state.selection.concat(action.payload));
} }
state.selection = uniq(state.selection.concat(action.payload));
}, },
imageSelected: (state, action: PayloadAction<string | null>) => { imageSelected: (state, action: PayloadAction<string | null>) => {
state.selection = action.payload state.selection = action.payload
@ -137,59 +195,210 @@ export const gallerySlice = createSlice({
setGalleryImageMinimumWidth: (state, action: PayloadAction<number>) => { setGalleryImageMinimumWidth: (state, action: PayloadAction<number>) => {
state.galleryImageMinimumWidth = action.payload; state.galleryImageMinimumWidth = action.payload;
}, },
setGalleryView: (state, action: PayloadAction<'images' | 'assets'>) => { setGalleryView: (state, action: PayloadAction<GalleryView>) => {
state.galleryView = action.payload; state.galleryView = action.payload;
}, },
boardIdSelected: (state, action: PayloadAction<string | undefined>) => { boardIdSelected: (state, action: PayloadAction<BoardPath>) => {
state.selectedBoardId = action.payload; const boardId = action.payload;
if (state.selectedBoardId === boardId) {
// selected same board, no-op
return;
}
state.selectedBoardId = boardId;
// handle selecting an unitialized board
const boardImagesId: BoardPath = `${boardId}.images`;
const boardAssetsId: BoardPath = `${boardId}.assets`;
if (!state.boards[boardImagesId]) {
state.boards[boardImagesId] = {
path: boardImagesId,
id: boardId,
view: 'images',
...initialBoardState,
};
}
if (!state.boards[boardAssetsId]) {
state.boards[boardAssetsId] = {
path: boardAssetsId,
id: boardId,
view: 'assets',
...initialBoardState,
};
}
// set the first image as selected
const firstImageName =
state.boards[`${boardId}.${state.galleryView}`].imageNames[0];
state.selection = firstImageName ? [firstImageName] : [];
}, },
isLoadingChanged: (state, action: PayloadAction<boolean>) => { isLoadingChanged: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload; state.isLoading = action.payload;
}, },
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder.addCase(receivedPageOfImages.pending, (state) => { /**
state.isFetching = true; * Image deleted
*/
builder.addCase(imageDeleted.pending, (state, action) => {
// optimistic update, but no undo :/
const { image_name } = action.meta.arg;
// remove image from all boards
forEach(state.boards, (board) => {
board.imageNames = board.imageNames.filter((n) => n !== image_name);
});
// and selection
state.selection = state.selection.filter((n) => n !== image_name);
}); });
builder.addCase(receivedPageOfImages.rejected, (state) => { /**
state.isFetching = false; * Images loaded into gallery - PENDING
*/
builder.addCase(imagesLoaded.pending, (state, action) => {
const { board_id, view } = action.meta.arg;
state.boards[`${board_id}.${view}`].status = 'pending';
}); });
builder.addCase(receivedPageOfImages.fulfilled, (state, action) => { /**
state.isFetching = false; * Images loaded into gallery - FULFILLED
const { board_id, categories, image_origin, is_intermediate } = */
action.meta.arg; builder.addCase(imagesLoaded.fulfilled, (state, action) => {
const { items, total } = action.payload;
const { board_id, view } = action.meta.arg;
const board = state.boards[`${board_id}.${view}`];
const { items, offset, limit, total } = action.payload; board.status = 'fulfilled';
imagesAdapter.upsertMany(state, items); board.imageNames = uniq(
board.imageNames.concat(items.map((i) => i.image_name))
);
board.total = total;
if (state.selection.length === 0 && items.length) { if (state.selection.length === 0 && items.length) {
state.selection = [items[0].image_name]; state.selection = [items[0].image_name];
} }
});
/**
* Images loaded into gallery - REJECTED
*/
builder.addCase(imagesLoaded.rejected, (state, action) => {
const { board_id, view } = action.meta.arg;
state.boards[`${board_id}.${view}`].status = 'rejected';
});
/**
* Image added to board
*/
builder.addMatcher(
boardImagesApi.endpoints.addBoardImage.matchFulfilled,
(state, action) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
// update user board stores
const userBoards = selectUserBoards(state);
userBoards.forEach((board) => {
// only update the current view
if (board.view !== state.galleryView) {
return;
}
if (!categories?.includes('general') || board_id) { if (board_id === board.id) {
// need to skip updating the total images count if the images recieved were for a specific board // add image to the board
// TODO: this doesn't work when on the Asset tab/category... board.imageNames = uniq(board.imageNames.concat(image_name));
return; } else {
// remove image from other boards
board.imageNames = board.imageNames.filter((n) => n !== image_name);
}
});
} }
);
state.offset = offset; /**
state.total = total; * Many images added to board
}); */
builder.addCase(imageUrlsReceived.fulfilled, (state, action) => { builder.addMatcher(
const { image_name, image_url, thumbnail_url } = action.payload; boardImagesApi.endpoints.addManyBoardImages.matchFulfilled,
(state, action) => {
imagesAdapter.updateOne(state, { const { board_id, image_names } = action.meta.arg.originalArgs;
id: image_name, // update local board stores
changes: { image_url, thumbnail_url }, forEach(state.boards, (board, board_id) => {
}); // only update the current view
}); if (board_id === board.id) {
// add images to the board
board.imageNames = uniq(board.imageNames.concat(image_names));
} else {
// remove images from other boards
board.imageNames = board.imageNames.filter((n) =>
image_names.includes(n)
);
}
});
}
);
/**
* Board deleted (not images)
*/
builder.addMatcher( builder.addMatcher(
boardsApi.endpoints.deleteBoard.matchFulfilled, boardsApi.endpoints.deleteBoard.matchFulfilled,
(state, action) => { (state, action) => {
if (action.meta.arg.originalArgs === state.selectedBoardId) { const deletedBoardId = action.meta.arg.originalArgs;
state.selectedBoardId = undefined; if (deletedBoardId === state.selectedBoardId) {
state.selectedBoardId = 'all';
} }
// remove board from local store
delete state.boards[`${deletedBoardId}.images`];
delete state.boards[`${deletedBoardId}.assets`];
}
);
/**
* Board deleted (with images)
*/
builder.addMatcher(
boardsApi.endpoints.deleteBoardAndImages.matchFulfilled,
(state, action) => {
const { deleted_images } = action.payload;
const deletedBoardId = action.meta.arg.originalArgs;
// remove images from all boards
forEach(state.boards, (board) => {
// remove images from all boards
board.imageNames = board.imageNames.filter((n) =>
deleted_images.includes(n)
);
});
delete state.boards[`${deletedBoardId}.images`];
delete state.boards[`${deletedBoardId}.assets`];
}
);
/**
* Image removed from board; i.e. Board reset for image
*/
builder.addMatcher(
boardImagesApi.endpoints.deleteBoardImage.matchFulfilled,
(state, action) => {
const { image_name } = action.meta.arg.originalArgs;
// remove from all user boards (skip all, none, batch)
const userBoards = selectUserBoards(state);
userBoards.forEach((board) => {
board.imageNames = board.imageNames.filter((n) => n !== image_name);
});
}
);
/**
* Many images removed from board; i.e. Board reset for many images
*/
builder.addMatcher(
boardImagesApi.endpoints.deleteManyBoardImages.matchFulfilled,
(state, action) => {
const { image_names } = action.meta.arg.originalArgs;
// remove images from all boards
forEach(state.imageNamesByIdAndView, (board) => {
// only update the current view
const view = board[state.galleryView];
view.imageNames = view.imageNames.filter((n) =>
image_names.includes(n)
);
});
} }
); );
}, },
@ -201,14 +410,10 @@ export const {
selectEntities: selectImagesEntities, selectEntities: selectImagesEntities,
selectIds: selectImagesIds, selectIds: selectImagesIds,
selectTotal: selectImagesTotal, selectTotal: selectImagesTotal,
} = imagesAdapter.getSelectors<RootState>((state) => state.gallery); } = galleryImagesAdapter.getSelectors<RootState>((state) => state.gallery);
export const { export const {
imageUpserted,
imageUpdatedOne,
imageRemoved,
imagesRemoved, imagesRemoved,
imageCategoriesChanged,
imageRangeEndSelected, imageRangeEndSelected,
imageSelectionToggled, imageSelectionToggled,
imageSelected, imageSelected,
@ -221,44 +426,12 @@ export const {
export default gallerySlice.reducer; export default gallerySlice.reducer;
export const selectFilteredImagesLocal = createSelector( const selectUserBoards = (state: typeof initialGalleryState) =>
(state: typeof initialGalleryState) => state, filter(state.boards, (board, path) => !systemBoards.includes(path));
(galleryState) => {
const allImages = imagesAdapter.getSelectors().selectAll(galleryState);
const { categories, selectedBoardId } = galleryState;
const filteredImages = allImages.filter((i) => { const selectCurrentBoard = (state: typeof initialGalleryState) =>
const isInCategory = categories.includes(i.image_category); state.boards[`${state.selectedBoardId}.${state.galleryView}`];
const isInSelectedBoard = selectedBoardId
? i.board_id === selectedBoardId
: true;
return isInCategory && isInSelectedBoard;
});
return filteredImages; const isImagesView = (board: BoardPath) => board.split('.')[1] === 'images';
}
);
export const selectFilteredImages = createSelector( const isAssetsView = (board: BoardPath) => board.split('.')[1] === 'assets';
(state: RootState) => state,
(state) => {
return selectFilteredImagesLocal(state.gallery);
},
defaultSelectorOptions
);
export const selectFilteredImagesAsObject = createSelector(
selectFilteredImages,
(filteredImages) => keyBy(filteredImages, 'image_name')
);
export const selectFilteredImagesIds = createSelector(
selectFilteredImages,
(filteredImages) => filteredImages.map((i) => i.image_name)
);
export const selectLastSelectedImage = createSelector(
(state: RootState) => state,
(state) => state.gallery.selection[state.gallery.selection.length - 1],
defaultSelectorOptions
);

View File

@ -121,8 +121,9 @@ const nodesSlice = createSlice({
) => { ) => {
state.invocationTemplates = action.payload; state.invocationTemplates = action.payload;
}, },
nodeEditorReset: () => { nodeEditorReset: (state) => {
return { ...initialNodesState }; state.nodes = [];
state.edges = [];
}, },
setEditorInstance: (state, action) => { setEditorInstance: (state, action) => {
state.editorInstance = action.payload; state.editorInstance = action.payload;

View File

@ -1,22 +1,22 @@
import { Flex, Spacer, Text } from '@chakra-ui/react'; import { Flex, Spacer, Text } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { useCallback, useMemo } from 'react';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { skipToken } from '@reduxjs/toolkit/dist/query'; import { skipToken } from '@reduxjs/toolkit/dist/query';
import IAIIconButton from 'common/components/IAIIconButton';
import { FaLayerGroup, FaUndo, FaUpload } from 'react-icons/fa';
import useImageUploader from 'common/hooks/useImageUploader';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import IAIButton from 'common/components/IAIButton';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIButton from 'common/components/IAIButton';
import IAIIconButton from 'common/components/IAIIconButton';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import useImageUploader from 'common/hooks/useImageUploader';
import BatchImageContainer from 'features/batch/components/BatchImageContainer';
import { import {
asInitialImageToggled, asInitialImageToggled,
batchReset, batchReset,
} from 'features/batch/store/batchSlice'; } from 'features/batch/store/batchSlice';
import BatchImageContainer from 'features/batch/components/BatchImageContainer'; import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { useCallback, useMemo } from 'react';
import { FaLayerGroup, FaUndo, FaUpload } from 'react-icons/fa';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { PostUploadAction } from 'services/api/thunks/image'; import { PostUploadAction } from 'services/api/thunks/image';
import InitialImage from './InitialImage'; import InitialImage from './InitialImage';
@ -114,7 +114,7 @@ const InitialImageDisplay = () => {
Initial Image Initial Image
</Text> </Text>
<Spacer /> <Spacer />
{/* <IAIButton <IAIButton
tooltip={useBatchAsInitialImage ? 'Disable Batch' : 'Enable Batch'} tooltip={useBatchAsInitialImage ? 'Disable Batch' : 'Enable Batch'}
aria-label={useBatchAsInitialImage ? 'Disable Batch' : 'Enable Batch'} aria-label={useBatchAsInitialImage ? 'Disable Batch' : 'Enable Batch'}
leftIcon={<FaLayerGroup />} leftIcon={<FaLayerGroup />}
@ -122,7 +122,7 @@ const InitialImageDisplay = () => {
onClick={handleClickUseBatch} onClick={handleClickUseBatch}
> >
{useBatchAsInitialImage ? 'Batch' : 'Single'} {useBatchAsInitialImage ? 'Batch' : 'Single'}
</IAIButton> */} </IAIButton>
<IAIIconButton <IAIIconButton
tooltip={ tooltip={
useBatchAsInitialImage ? 'Upload to Batch' : 'Upload Initial Image' useBatchAsInitialImage ? 'Upload to Batch' : 'Upload Initial Image'
@ -146,8 +146,7 @@ const InitialImageDisplay = () => {
isDisabled={isResetButtonDisabled} isDisabled={isResetButtonDisabled}
/> />
</Flex> </Flex>
<InitialImage /> {useBatchAsInitialImage ? <BatchImageContainer /> : <InitialImage />}
{/* {useBatchAsInitialImage ? <BatchImageContainer /> : <InitialImage />} */}
<input {...getUploadInputProps()} /> <input {...getUploadInputProps()} />
</Flex> </Flex>
); );

View File

@ -24,7 +24,7 @@ import { isEqual } from 'lodash-es';
import { MouseEvent, ReactNode, memo, useCallback, useMemo } from 'react'; import { MouseEvent, ReactNode, memo, useCallback, useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FaCube, FaFont, FaImage } from 'react-icons/fa'; import { FaCube, FaFont, FaImage, FaLayerGroup } from 'react-icons/fa';
import { MdDeviceHub, MdGridOn } from 'react-icons/md'; import { MdDeviceHub, MdGridOn } from 'react-icons/md';
import { Panel, PanelGroup } from 'react-resizable-panels'; import { Panel, PanelGroup } from 'react-resizable-panels';
import { useMinimumPanelSize } from '../hooks/useMinimumPanelSize'; import { useMinimumPanelSize } from '../hooks/useMinimumPanelSize';
@ -32,6 +32,7 @@ import {
activeTabIndexSelector, activeTabIndexSelector,
activeTabNameSelector, activeTabNameSelector,
} from '../store/uiSelectors'; } from '../store/uiSelectors';
import BatchTab from './tabs/Batch/BatchTab';
import ImageTab from './tabs/ImageToImage/ImageToImageTab'; import ImageTab from './tabs/ImageToImage/ImageToImageTab';
import ModelManagerTab from './tabs/ModelManager/ModelManagerTab'; import ModelManagerTab from './tabs/ModelManager/ModelManagerTab';
import NodesTab from './tabs/Nodes/NodesTab'; import NodesTab from './tabs/Nodes/NodesTab';
@ -78,11 +79,12 @@ const tabs: InvokeTabInfo[] = [
icon: <Icon as={FaCube} sx={{ boxSize: 6, pointerEvents: 'none' }} />, icon: <Icon as={FaCube} sx={{ boxSize: 6, pointerEvents: 'none' }} />,
content: <ModelManagerTab />, content: <ModelManagerTab />,
}, },
// { {
// id: 'batch', id: 'batch',
// icon: <Icon as={FaLayerGroup} sx={{ boxSize: 6, pointerEvents: 'none' }} />, translationKey: 'modelManager.modelManager',
// content: <BatchTab />, icon: <Icon as={FaLayerGroup} sx={{ boxSize: 6, pointerEvents: 'none' }} />,
// }, content: <BatchTab />,
},
]; ];
const enabledTabsSelector = createSelector( const enabledTabsSelector = createSelector(

View File

@ -1,44 +1,57 @@
import { OffsetPaginatedResults_ImageDTO_ } from 'services/api/types'; import { PatchCollection } from '@reduxjs/toolkit/dist/query/core/buildThunks';
import { api } from '..'; import { ApiFullTagDescription, LIST_TAG, api } from '..';
import { paths } from '../schema'; import { components, paths } from '../schema';
import { imagesApi } from './images'; import { imagesApi } from './images';
type ListBoardImagesArg =
paths['/api/v1/board_images/{board_id}']['get']['parameters']['path'] &
paths['/api/v1/board_images/{board_id}']['get']['parameters']['query'];
type AddImageToBoardArg = type AddImageToBoardArg =
paths['/api/v1/board_images/']['post']['requestBody']['content']['application/json']; paths['/api/v1/board_images/{board_id}']['post']['requestBody']['content']['application/json'];
type AddManyImagesToBoardArg =
paths['/api/v1/board_images/{board_id}/images']['patch']['requestBody']['content']['application/json'];
type RemoveImageFromBoardArg = type RemoveImageFromBoardArg =
paths['/api/v1/board_images/']['delete']['requestBody']['content']['application/json']; paths['/api/v1/board_images/']['delete']['requestBody']['content']['application/json'];
type RemoveManyBoardImagesArg =
paths['/api/v1/board_images/images']['post']['requestBody']['content']['application/json'];
type GetAllBoardImagesForBoardResult =
components['schemas']['GetAllBoardImagesForBoardResult'];
export const boardImagesApi = api.injectEndpoints({ export const boardImagesApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
/** /**
* Board Images Queries * Board Images Queries
*/ */
listBoardImages: build.query< getAllBoardImagesForBoard: build.query<
OffsetPaginatedResults_ImageDTO_, GetAllBoardImagesForBoardResult,
ListBoardImagesArg { board_id: string }
>({ >({
query: ({ board_id, offset, limit }) => ({ query: ({ board_id }) => ({
url: `board_images/${board_id}`, url: `board_images/${board_id}`,
method: 'DELETE', method: 'GET',
body: { offset, limit },
}), }),
providesTags: (result, error, arg) => [
{
type: 'Board',
id: arg.board_id,
},
],
}), }),
/** /**
* Board Images Mutations * Board Images Mutations
*/ */
addImageToBoard: build.mutation<void, AddImageToBoardArg>({ addBoardImage: build.mutation<
void,
{ board_id: string; image_name: string }
>({
query: ({ board_id, image_name }) => ({ query: ({ board_id, image_name }) => ({
url: `board_images/`, url: `board_images/${board_id}`,
method: 'POST', method: 'POST',
body: { board_id, image_name }, body: image_name,
}), }),
invalidatesTags: (result, error, arg) => [ invalidatesTags: (result, error, arg) => [
{ type: 'Board', id: arg.board_id }, { type: 'Board', id: arg.board_id },
@ -60,19 +73,55 @@ export const boardImagesApi = api.injectEndpoints({
}, },
}), }),
removeImageFromBoard: build.mutation<void, RemoveImageFromBoardArg>({ addManyBoardImages: build.mutation<
query: ({ board_id, image_name }) => ({ string[],
url: `board_images/`, { board_id: string; image_names: string[] }
method: 'DELETE', >({
body: { board_id, image_name }, query: ({ board_id, image_names }) => ({
url: `board_images/${board_id}/images`,
method: 'PATCH',
body: image_names,
}), }),
invalidatesTags: (result, error, arg) => [ invalidatesTags: (result, error, arg) => {
{ type: 'Board', id: arg.board_id }, const tags: ApiFullTagDescription[] = [
], { type: 'Board', id: arg.board_id },
{ type: 'Board', id: LIST_TAG },
];
return tags;
},
async onQueryStarted( async onQueryStarted(
{ image_name, ...patch }, { image_names, board_id },
{ dispatch, queryFulfilled } { dispatch, queryFulfilled }
) { ) {
const patches: PatchCollection[] = [];
image_names.forEach((n) => {
const patchResult = dispatch(
imagesApi.util.updateQueryData('getImageDTO', n, (draft) => {
Object.assign(draft, { board_id });
})
);
patches.push(patchResult);
});
try {
await queryFulfilled;
} catch {
patches.forEach((p) => p.undo());
}
},
}),
deleteBoardImage: build.mutation<void, { image_name: string }>({
query: ({ image_name }) => ({
url: `board_images/`,
method: 'DELETE',
body: image_name,
}),
invalidatesTags: (result, error, arg) => [
{ type: 'Board', id: LIST_TAG },
],
async onQueryStarted({ image_name }, { dispatch, queryFulfilled }) {
const patchResult = dispatch( const patchResult = dispatch(
imagesApi.util.updateQueryData('getImageDTO', image_name, (draft) => { imagesApi.util.updateQueryData('getImageDTO', image_name, (draft) => {
Object.assign(draft, { board_id: null }); Object.assign(draft, { board_id: null });
@ -85,11 +134,42 @@ export const boardImagesApi = api.injectEndpoints({
} }
}, },
}), }),
deleteManyBoardImages: build.mutation<void, { image_names: string[] }>({
query: ({ image_names }) => ({
url: `board_images/images`,
method: 'POST',
body: image_names,
}),
invalidatesTags: (result, error, arg) => [
{ type: 'Board', id: LIST_TAG },
],
async onQueryStarted({ image_names }, { dispatch, queryFulfilled }) {
const patches: PatchCollection[] = [];
image_names.forEach((n) => {
const patchResult = dispatch(
imagesApi.util.updateQueryData('getImageDTO', n, (draft) => {
Object.assign(draft, { board_id: null });
})
);
patches.push(patchResult);
});
try {
await queryFulfilled;
} catch {
patches.forEach((p) => p.undo());
}
},
}),
}), }),
}); });
export const { export const {
useAddImageToBoardMutation, useGetAllBoardImagesForBoardQuery,
useRemoveImageFromBoardMutation, useAddBoardImageMutation,
useListBoardImagesQuery, useAddManyBoardImagesMutation,
useDeleteBoardImageMutation,
useDeleteManyBoardImagesMutation,
} = boardImagesApi; } = boardImagesApi;

View File

@ -1,6 +1,6 @@
import { BoardDTO, OffsetPaginatedResults_BoardDTO_ } from 'services/api/types'; import { BoardDTO, OffsetPaginatedResults_BoardDTO_ } from 'services/api/types';
import { ApiFullTagDescription, LIST_TAG, api } from '..'; import { ApiFullTagDescription, LIST_TAG, api } from '..';
import { paths } from '../schema'; import { components, paths } from '../schema';
type ListBoardsArg = NonNullable< type ListBoardsArg = NonNullable<
paths['/api/v1/boards/']['get']['parameters']['query'] paths['/api/v1/boards/']['get']['parameters']['query']
@ -20,7 +20,7 @@ export const boardsApi = api.injectEndpoints({
query: (arg) => ({ url: 'boards/', params: arg }), query: (arg) => ({ url: 'boards/', params: arg }),
providesTags: (result, error, arg) => { providesTags: (result, error, arg) => {
// any list of boards // any list of boards
const tags: ApiFullTagDescription[] = [{ id: 'Board', type: LIST_TAG }]; const tags: ApiFullTagDescription[] = [{ type: 'Board', id: LIST_TAG }];
if (result) { if (result) {
// and individual tags for each board // and individual tags for each board
@ -43,7 +43,7 @@ export const boardsApi = api.injectEndpoints({
}), }),
providesTags: (result, error, arg) => { providesTags: (result, error, arg) => {
// any list of boards // any list of boards
const tags: ApiFullTagDescription[] = [{ id: 'Board', type: LIST_TAG }]; const tags: ApiFullTagDescription[] = [{ type: 'Board', id: LIST_TAG }];
if (result) { if (result) {
// and individual tags for each board // and individual tags for each board
@ -69,7 +69,7 @@ export const boardsApi = api.injectEndpoints({
method: 'POST', method: 'POST',
params: { board_name }, params: { board_name },
}), }),
invalidatesTags: [{ id: 'Board', type: LIST_TAG }], invalidatesTags: [{ type: 'Board', id: LIST_TAG }],
}), }),
updateBoard: build.mutation<BoardDTO, UpdateBoardArg>({ updateBoard: build.mutation<BoardDTO, UpdateBoardArg>({
@ -86,9 +86,19 @@ export const boardsApi = api.injectEndpoints({
query: (board_id) => ({ url: `boards/${board_id}`, method: 'DELETE' }), query: (board_id) => ({ url: `boards/${board_id}`, method: 'DELETE' }),
invalidatesTags: (result, error, arg) => [{ type: 'Board', id: arg }], invalidatesTags: (result, error, arg) => [{ type: 'Board', id: arg }],
}), }),
deleteBoardAndImages: build.mutation<void, string>({ deleteBoardAndImages: build.mutation<
query: (board_id) => ({ url: `boards/${board_id}`, method: 'DELETE', params: { include_images: true } }), components['schemas']['DeleteManyImagesResult'],
invalidatesTags: (result, error, arg) => [{ type: 'Board', id: arg }, { type: 'Image', id: LIST_TAG }], string
>({
query: (board_id) => ({
url: `boards/${board_id}`,
method: 'DELETE',
params: { include_images: true },
}),
invalidatesTags: (result, error, arg) => [
{ type: 'Board', id: arg },
{ type: 'Image', id: LIST_TAG },
],
}), }),
}), }),
}); });
@ -99,5 +109,5 @@ export const {
useCreateBoardMutation, useCreateBoardMutation,
useUpdateBoardMutation, useUpdateBoardMutation,
useDeleteBoardMutation, useDeleteBoardMutation,
useDeleteBoardAndImagesMutation useDeleteBoardAndImagesMutation,
} = boardsApi; } = boardsApi;

View File

@ -1,4 +1,4 @@
import { ApiFullTagDescription, api } from '..'; import { api } from '..';
import { ImageDTO } from '../types'; import { ImageDTO } from '../types';
export const imagesApi = api.injectEndpoints({ export const imagesApi = api.injectEndpoints({
@ -7,14 +7,8 @@ export const imagesApi = api.injectEndpoints({
* Image Queries * Image Queries
*/ */
getImageDTO: build.query<ImageDTO, string>({ getImageDTO: build.query<ImageDTO, string>({
query: (image_name) => ({ url: `images/${image_name}/metadata` }), query: (image_name) => ({ url: `images/${image_name}` }),
providesTags: (result, error, arg) => { providesTags: (result, error, arg) => [{ type: 'Image', id: arg }],
const tags: ApiFullTagDescription[] = [{ type: 'Image', id: arg }];
if (result?.board_id) {
tags.push({ type: 'Board', id: result.board_id });
}
return tags;
},
keepUnusedDataFor: 86400, // 24 hours keepUnusedDataFor: 86400, // 24 hours
}), }),
}), }),

View File

@ -73,7 +73,7 @@ export const modelsApi = api.injectEndpoints({
query: () => ({ url: 'models/', params: { model_type: 'main' } }), query: () => ({ url: 'models/', params: { model_type: 'main' } }),
providesTags: (result, error, arg) => { providesTags: (result, error, arg) => {
const tags: ApiFullTagDescription[] = [ const tags: ApiFullTagDescription[] = [
{ id: 'MainModel', type: LIST_TAG }, { type: 'MainModel', id: LIST_TAG },
]; ];
if (result) { if (result) {
@ -105,7 +105,7 @@ export const modelsApi = api.injectEndpoints({
query: () => ({ url: 'models/', params: { model_type: 'lora' } }), query: () => ({ url: 'models/', params: { model_type: 'lora' } }),
providesTags: (result, error, arg) => { providesTags: (result, error, arg) => {
const tags: ApiFullTagDescription[] = [ const tags: ApiFullTagDescription[] = [
{ id: 'LoRAModel', type: LIST_TAG }, { type: 'LoRAModel', id: LIST_TAG },
]; ];
if (result) { if (result) {
@ -140,7 +140,7 @@ export const modelsApi = api.injectEndpoints({
query: () => ({ url: 'models/', params: { model_type: 'controlnet' } }), query: () => ({ url: 'models/', params: { model_type: 'controlnet' } }),
providesTags: (result, error, arg) => { providesTags: (result, error, arg) => {
const tags: ApiFullTagDescription[] = [ const tags: ApiFullTagDescription[] = [
{ id: 'ControlNetModel', type: LIST_TAG }, { type: 'ControlNetModel', id: LIST_TAG },
]; ];
if (result) { if (result) {
@ -172,7 +172,7 @@ export const modelsApi = api.injectEndpoints({
query: () => ({ url: 'models/', params: { model_type: 'vae' } }), query: () => ({ url: 'models/', params: { model_type: 'vae' } }),
providesTags: (result, error, arg) => { providesTags: (result, error, arg) => {
const tags: ApiFullTagDescription[] = [ const tags: ApiFullTagDescription[] = [
{ id: 'VaeModel', type: LIST_TAG }, { type: 'VaeModel', id: LIST_TAG },
]; ];
if (result) { if (result) {
@ -207,7 +207,7 @@ export const modelsApi = api.injectEndpoints({
query: () => ({ url: 'models/', params: { model_type: 'embedding' } }), query: () => ({ url: 'models/', params: { model_type: 'embedding' } }),
providesTags: (result, error, arg) => { providesTags: (result, error, arg) => {
const tags: ApiFullTagDescription[] = [ const tags: ApiFullTagDescription[] = [
{ id: 'TextualInversionModel', type: LIST_TAG }, { type: 'TextualInversionModel', id: LIST_TAG },
]; ];
if (result) { if (result) {

View File

@ -107,12 +107,7 @@ export type paths = {
*/ */
put: operations["merge_models"]; put: operations["merge_models"];
}; };
"/api/v1/images/": { "/api/v1/images/upload": {
/**
* List Images With Metadata
* @description Gets a list of images
*/
get: operations["list_images_with_metadata"];
/** /**
* Upload Image * Upload Image
* @description Uploads an image * @description Uploads an image
@ -121,10 +116,10 @@ export type paths = {
}; };
"/api/v1/images/{image_name}": { "/api/v1/images/{image_name}": {
/** /**
* Get Image Full * Get Image Dto
* @description Gets a full-resolution image file * @description Gets an image's DTO
*/ */
get: operations["get_image_full"]; get: operations["get_image"];
/** /**
* Delete Image * Delete Image
* @description Deletes an image * @description Deletes an image
@ -136,12 +131,12 @@ export type paths = {
*/ */
patch: operations["update_image"]; patch: operations["update_image"];
}; };
"/api/v1/images/{image_name}/metadata": { "/api/v1/images/{image_name}/full_size": {
/** /**
* Get Image Metadata * Get Image Full Size
* @description Gets an image's metadata * @description Gets a full-resolution image file
*/ */
get: operations["get_image_metadata"]; get: operations["get_image_full_size"];
}; };
"/api/v1/images/{image_name}/thumbnail": { "/api/v1/images/{image_name}/thumbnail": {
/** /**
@ -157,6 +152,25 @@ export type paths = {
*/ */
get: operations["get_image_urls"]; get: operations["get_image_urls"];
}; };
"/api/v1/images/": {
/**
* Get Many Images
* @description Gets a list of images
*/
get: operations["get_many_images"];
/**
* Get Images By Names
* @description Gets a list of images
*/
post: operations["get_images_by_names"];
};
"/api/v1/images/delete": {
/**
* Delete Many Images
* @description Deletes many images
*/
post: operations["delete_many_images"];
};
"/api/v1/boards/": { "/api/v1/boards/": {
/** /**
* List Boards * List Boards
@ -186,24 +200,38 @@ export type paths = {
*/ */
patch: operations["update_board"]; patch: operations["update_board"];
}; };
"/api/v1/board_images/": { "/api/v1/board_images/{board_id}": {
/**
* Get All Board Images For Board
* @description Gets all image names for a board
*/
get: operations["get_all_board_images_for_board"];
/** /**
* Create Board Image * Create Board Image
* @description Creates a board_image * @description Creates a board_image
*/ */
post: operations["create_board_image"]; post: operations["create_board_image"];
};
"/api/v1/board_images/": {
/** /**
* Remove Board Image * Remove Board Image
* @description Deletes a board_image * @description Deletes a board_image
*/ */
delete: operations["remove_board_image"]; delete: operations["remove_board_image"];
}; };
"/api/v1/board_images/{board_id}": { "/api/v1/board_images/{board_id}/images": {
/** /**
* List Board Images * Create Multiple Board Images
* @description Gets a list of images for a board * @description Add many images to a board
*/ */
get: operations["list_board_images"]; patch: operations["create_multiple_board_images"];
};
"/api/v1/board_images/images": {
/**
* Delete Multiple Board Images
* @description Remove many images from their boards, if they have one
*/
post: operations["delete_multiple_board_images"];
}; };
"/api/v1/app/version": { "/api/v1/app/version": {
/** Get Version */ /** Get Version */
@ -318,19 +346,6 @@ export type components = {
*/ */
image_count: number; image_count: number;
}; };
/** Body_create_board_image */
Body_create_board_image: {
/**
* Board Id
* @description The id of the board to add to
*/
board_id: string;
/**
* Image Name
* @description The name of the image to add
*/
image_name: string;
};
/** Body_import_model */ /** Body_import_model */
Body_import_model: { Body_import_model: {
/** /**
@ -373,19 +388,6 @@ export type components = {
*/ */
force?: boolean; force?: boolean;
}; };
/** Body_remove_board_image */
Body_remove_board_image: {
/**
* Board Id
* @description The id of the board
*/
board_id: string;
/**
* Image Name
* @description The name of the image to remove
*/
image_name: string;
};
/** Body_upload_image */ /** Body_upload_image */
Body_upload_image: { Body_upload_image: {
/** /**
@ -869,6 +871,17 @@ export type components = {
*/ */
mask?: components["schemas"]["ImageField"]; mask?: components["schemas"]["ImageField"];
}; };
/**
* DeleteManyImagesResult
* @description The result of a delete many image operation.
*/
DeleteManyImagesResult: {
/**
* Deleted Images
* @description The names of the images that were successfully deleted
*/
deleted_images: (string)[];
};
/** /**
* DivideInvocation * DivideInvocation
* @description Divides two numbers * @description Divides two numbers
@ -1046,6 +1059,33 @@ export type components = {
*/ */
param?: number; param?: number;
}; };
/**
* GetAllBoardImagesForBoardResult
* @description The result of a get all image names for board operation.
*/
GetAllBoardImagesForBoardResult: {
/**
* Board Id
* @description The id of the board with which the images are associated
*/
board_id: string;
/**
* Image Names
* @description The names of the images that are associated with the board
*/
image_names: (string)[];
};
/**
* GetImagesByNamesResult
* @description The result of a get all image names for board operation.
*/
GetImagesByNamesResult: {
/**
* Image Dtos
* @description The names of the images that are associated with the board
*/
image_dtos: (components["schemas"]["ImageDTO"])[];
};
/** Graph */ /** Graph */
Graph: { Graph: {
/** /**
@ -1058,7 +1098,7 @@ export type components = {
* @description The nodes in this graph * @description The nodes in this graph
*/ */
nodes?: { nodes?: {
[key: string]: (components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"]) | undefined; [key: string]: (components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"]) | undefined;
}; };
/** /**
* Edges * Edges
@ -1101,7 +1141,7 @@ export type components = {
* @description The results of node executions * @description The results of node executions
*/ */
results: { results: {
[key: string]: (components["schemas"]["IntCollectionOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["LoraLoaderOutput"] | components["schemas"]["VaeLoaderOutput"] | components["schemas"]["CompelOutput"] | components["schemas"]["ClipSkipInvocationOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["IntOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PromptOutput"] | components["schemas"]["PromptCollectionOutput"] | components["schemas"]["GraphInvocationOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["CollectInvocationOutput"]) | undefined; [key: string]: (components["schemas"]["ImageOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["LoraLoaderOutput"] | components["schemas"]["VaeLoaderOutput"] | components["schemas"]["PromptOutput"] | components["schemas"]["PromptCollectionOutput"] | components["schemas"]["CompelOutput"] | components["schemas"]["ClipSkipInvocationOutput"] | components["schemas"]["IntOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["IntCollectionOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["GraphInvocationOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["CollectInvocationOutput"]) | undefined;
}; };
/** /**
* Errors * Errors
@ -4425,18 +4465,18 @@ export type components = {
*/ */
image?: components["schemas"]["ImageField"]; image?: components["schemas"]["ImageField"];
}; };
/**
* StableDiffusion2ModelFormat
* @description An enumeration.
* @enum {string}
*/
StableDiffusion2ModelFormat: "checkpoint" | "diffusers";
/** /**
* StableDiffusion1ModelFormat * StableDiffusion1ModelFormat
* @description An enumeration. * @description An enumeration.
* @enum {string} * @enum {string}
*/ */
StableDiffusion1ModelFormat: "checkpoint" | "diffusers"; StableDiffusion1ModelFormat: "checkpoint" | "diffusers";
/**
* StableDiffusion2ModelFormat
* @description An enumeration.
* @enum {string}
*/
StableDiffusion2ModelFormat: "checkpoint" | "diffusers";
}; };
responses: never; responses: never;
parameters: never; parameters: never;
@ -4547,7 +4587,7 @@ export type operations = {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"]; "application/json": components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"];
}; };
}; };
responses: { responses: {
@ -4584,7 +4624,7 @@ export type operations = {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"]; "application/json": components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"];
}; };
}; };
responses: { responses: {
@ -4976,42 +5016,6 @@ export type operations = {
}; };
}; };
}; };
/**
* List Images With Metadata
* @description Gets a list of images
*/
list_images_with_metadata: {
parameters: {
query?: {
/** @description The origin of images to list */
image_origin?: components["schemas"]["ResourceOrigin"];
/** @description The categories of image to include */
categories?: (components["schemas"]["ImageCategory"])[];
/** @description Whether to list intermediate images */
is_intermediate?: boolean;
/** @description The board id to filter by */
board_id?: string;
/** @description The page offset */
offset?: number;
/** @description The number of images per page */
limit?: number;
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": components["schemas"]["OffsetPaginatedResults_ImageDTO_"];
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
/** /**
* Upload Image * Upload Image
* @description Uploads an image * @description Uploads an image
@ -5050,25 +5054,23 @@ export type operations = {
}; };
}; };
/** /**
* Get Image Full * Get Image Dto
* @description Gets a full-resolution image file * @description Gets an image's DTO
*/ */
get_image_full: { get_image: {
parameters: { parameters: {
path: { path: {
/** @description The name of full-resolution image file to get */ /** @description The name of image to get */
image_name: string; image_name: string;
}; };
}; };
responses: { responses: {
/** @description Return the full-resolution image */ /** @description Successful Response */
200: { 200: {
content: { content: {
"image/png": unknown; "application/json": components["schemas"]["ImageDTO"];
}; };
}; };
/** @description Image not found */
404: never;
/** @description Validation Error */ /** @description Validation Error */
422: { 422: {
content: { content: {
@ -5135,23 +5137,25 @@ export type operations = {
}; };
}; };
/** /**
* Get Image Metadata * Get Image Full Size
* @description Gets an image's metadata * @description Gets a full-resolution image file
*/ */
get_image_metadata: { get_image_full_size: {
parameters: { parameters: {
path: { path: {
/** @description The name of image to get */ /** @description The name of full-resolution image file to get */
image_name: string; image_name: string;
}; };
}; };
responses: { responses: {
/** @description Successful Response */ /** @description Return the full-resolution image */
200: { 200: {
content: { content: {
"application/json": components["schemas"]["ImageDTO"]; "image/png": unknown;
}; };
}; };
/** @description Image not found */
404: never;
/** @description Validation Error */ /** @description Validation Error */
422: { 422: {
content: { content: {
@ -5214,6 +5218,92 @@ export type operations = {
}; };
}; };
}; };
/**
* Get Many Images
* @description Gets a list of images
*/
get_many_images: {
parameters: {
query?: {
/** @description The origin of images to list */
image_origin?: components["schemas"]["ResourceOrigin"];
/** @description The categories of image to include */
categories?: (components["schemas"]["ImageCategory"])[];
/** @description Whether to list intermediate images */
is_intermediate?: boolean;
/** @description The board id to filter by, provide 'none' for images without a board */
board_id?: string;
/** @description The page offset */
offset?: number;
/** @description The number of images per page */
limit?: number;
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": components["schemas"]["OffsetPaginatedResults_ImageDTO_"];
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
/**
* Get Images By Names
* @description Gets a list of images
*/
get_images_by_names: {
requestBody: {
content: {
"application/json": (string)[];
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": components["schemas"]["GetImagesByNamesResult"];
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
/**
* Delete Many Images
* @description Deletes many images
*/
delete_many_images: {
requestBody: {
content: {
"application/json": (string)[];
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": components["schemas"]["DeleteManyImagesResult"];
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
/** /**
* List Boards * List Boards
* @description Gets a list of boards * @description Gets a list of boards
@ -5315,7 +5405,7 @@ export type operations = {
/** @description Successful Response */ /** @description Successful Response */
200: { 200: {
content: { content: {
"application/json": unknown; "application/json": components["schemas"]["DeleteManyImagesResult"];
}; };
}; };
/** @description Validation Error */ /** @description Validation Error */
@ -5357,14 +5447,46 @@ export type operations = {
}; };
}; };
}; };
/**
* Get All Board Images For Board
* @description Gets all image names for a board
*/
get_all_board_images_for_board: {
parameters: {
path: {
/** @description The id of the board */
board_id: string;
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": components["schemas"]["GetAllBoardImagesForBoardResult"];
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
/** /**
* Create Board Image * Create Board Image
* @description Creates a board_image * @description Creates a board_image
*/ */
create_board_image: { create_board_image: {
parameters: {
path: {
/** @description The id of the board to add to */
board_id: string;
};
};
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["Body_create_board_image"]; "application/json": string;
}; };
}; };
responses: { responses: {
@ -5389,7 +5511,7 @@ export type operations = {
remove_board_image: { remove_board_image: {
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["Body_remove_board_image"]; "application/json": string;
}; };
}; };
responses: { responses: {
@ -5408,27 +5530,51 @@ export type operations = {
}; };
}; };
/** /**
* List Board Images * Create Multiple Board Images
* @description Gets a list of images for a board * @description Add many images to a board
*/ */
list_board_images: { create_multiple_board_images: {
parameters: { parameters: {
query?: {
/** @description The page offset */
offset?: number;
/** @description The number of boards per page */
limit?: number;
};
path: { path: {
/** @description The id of the board */ /** @description The id of the board */
board_id: string; board_id: string;
}; };
}; };
requestBody: {
content: {
"application/json": (string)[];
};
};
responses: { responses: {
/** @description Successful Response */ /** @description The images were added to the board successfully */
200: { 201: {
content: { content: {
"application/json": components["schemas"]["OffsetPaginatedResults_ImageDTO_"]; "application/json": unknown;
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
/**
* Delete Multiple Board Images
* @description Remove many images from their boards, if they have one
*/
delete_multiple_board_images: {
requestBody: {
content: {
"application/json": (string)[];
};
};
responses: {
/** @description The images were removed from their boards successfully */
201: {
content: {
"application/json": unknown;
}; };
}; };
/** @description Validation Error */ /** @description Validation Error */

View File

@ -0,0 +1,41 @@
import { createAppAsyncThunk } from 'app/store/storeUtils';
import { $client } from '../client';
import { components } from '../schema';
type Arg = { board_id: string };
type GetAllBoardImagesForBoardResult =
components['schemas']['GetAllBoardImagesForBoardResult'];
type GetImageUrlsThunkConfig = {
rejectValue: {
arg: Arg;
error: unknown;
};
};
/**
* Thunk to get image URLs
*/
export const boardImageNamesReceived = createAppAsyncThunk<
GetAllBoardImagesForBoardResult,
Arg,
GetImageUrlsThunkConfig
>('thunkApi/boardImageNamesReceived', async (arg, { rejectWithValue }) => {
const { get } = $client.get();
const { data, error, response } = await get(
'/api/v1/board_images/{board_id}',
{
params: {
path: {
board_id: arg.board_id,
},
},
}
);
if (error) {
return rejectWithValue({ arg, error });
}
return data;
});

View File

@ -1,9 +1,10 @@
import queryString from 'query-string';
import { createAppAsyncThunk } from 'app/store/storeUtils'; import { createAppAsyncThunk } from 'app/store/storeUtils';
import { selectImagesAll } from 'features/gallery/store/gallerySlice'; import { selectImagesAll } from 'features/gallery/store/gallerySlice';
import { size } from 'lodash-es'; import { size } from 'lodash-es';
import { paths } from 'services/api/schema'; import queryString from 'query-string';
import { $client } from 'services/api/client'; import { $client } from 'services/api/client';
import { paths } from 'services/api/schema';
import { ImageCategory, OffsetPaginatedResults_ImageDTO_ } from '../types';
type GetImageUrlsArg = type GetImageUrlsArg =
paths['/api/v1/images/{image_name}/urls']['get']['parameters']['path']; paths['/api/v1/images/{image_name}/urls']['get']['parameters']['path'];
@ -24,7 +25,7 @@ export const imageUrlsReceived = createAppAsyncThunk<
GetImageUrlsResponse, GetImageUrlsResponse,
GetImageUrlsArg, GetImageUrlsArg,
GetImageUrlsThunkConfig GetImageUrlsThunkConfig
>('api/imageUrlsReceived', async (arg, { rejectWithValue }) => { >('thunkApi/imageUrlsReceived', async (arg, { rejectWithValue }) => {
const { image_name } = arg; const { image_name } = arg;
const { get } = $client.get(); const { get } = $client.get();
const { data, error, response } = await get( const { data, error, response } = await get(
@ -45,34 +46,31 @@ export const imageUrlsReceived = createAppAsyncThunk<
return data; return data;
}); });
type GetImageMetadataArg = type GetImageDTOArg =
paths['/api/v1/images/{image_name}/metadata']['get']['parameters']['path']; paths['/api/v1/images/{image_name}']['get']['parameters']['path'];
type GetImageMetadataResponse = type GetImageDTOResponse =
paths['/api/v1/images/{image_name}/metadata']['get']['responses']['200']['content']['application/json']; paths['/api/v1/images/{image_name}']['get']['responses']['200']['content']['application/json'];
type GetImageMetadataThunkConfig = { type GetImageDTOThunkConfig = {
rejectValue: { rejectValue: {
arg: GetImageMetadataArg; arg: GetImageDTOArg;
error: unknown; error: unknown;
}; };
}; };
export const imageMetadataReceived = createAppAsyncThunk< export const imageDTOReceived = createAppAsyncThunk<
GetImageMetadataResponse, GetImageDTOResponse,
GetImageMetadataArg, GetImageDTOArg,
GetImageMetadataThunkConfig GetImageDTOThunkConfig
>('api/imageMetadataReceived', async (arg, { rejectWithValue }) => { >('thunkApi/imageDTOReceived', async (arg, { rejectWithValue }) => {
const { image_name } = arg; const { image_name } = arg;
const { get } = $client.get(); const { get } = $client.get();
const { data, error, response } = await get( const { data, error, response } = await get('/api/v1/images/{image_name}', {
'/api/v1/images/{image_name}/metadata', params: {
{ path: { image_name },
params: { },
path: { image_name }, });
},
}
);
if (error) { if (error) {
return rejectWithValue({ arg, error }); return rejectWithValue({ arg, error });
@ -127,13 +125,13 @@ export type PostUploadAction =
| AddToBatchAction; | AddToBatchAction;
type UploadImageArg = type UploadImageArg =
paths['/api/v1/images/']['post']['parameters']['query'] & { paths['/api/v1/images/upload']['post']['parameters']['query'] & {
file: File; file: File;
postUploadAction?: PostUploadAction; postUploadAction?: PostUploadAction;
}; };
type UploadImageResponse = type UploadImageResponse =
paths['/api/v1/images/']['post']['responses']['201']['content']['application/json']; paths['/api/v1/images/upload']['post']['responses']['201']['content']['application/json'];
type UploadImageThunkConfig = { type UploadImageThunkConfig = {
rejectValue: { rejectValue: {
@ -148,7 +146,7 @@ export const imageUploaded = createAppAsyncThunk<
UploadImageResponse, UploadImageResponse,
UploadImageArg, UploadImageArg,
UploadImageThunkConfig UploadImageThunkConfig
>('api/imageUploaded', async (arg, { rejectWithValue }) => { >('thunkApi/imageUploaded', async (arg, { rejectWithValue }) => {
const { const {
postUploadAction, postUploadAction,
file, file,
@ -157,7 +155,7 @@ export const imageUploaded = createAppAsyncThunk<
session_id, session_id,
} = arg; } = arg;
const { post } = $client.get(); const { post } = $client.get();
const { data, error, response } = await post('/api/v1/images/', { const { data, error, response } = await post('/api/v1/images/upload', {
params: { params: {
query: { query: {
image_category, image_category,
@ -199,7 +197,7 @@ export const imageDeleted = createAppAsyncThunk<
DeleteImageResponse, DeleteImageResponse,
DeleteImageArg, DeleteImageArg,
DeleteImageThunkConfig DeleteImageThunkConfig
>('api/imageDeleted', async (arg, { rejectWithValue }) => { >('thunkApi/imageDeleted', async (arg, { rejectWithValue }) => {
const { image_name } = arg; const { image_name } = arg;
const { del } = $client.get(); const { del } = $client.get();
const { data, error, response } = await del('/api/v1/images/{image_name}', { const { data, error, response } = await del('/api/v1/images/{image_name}', {
@ -235,7 +233,7 @@ export const imageUpdated = createAppAsyncThunk<
UpdateImageResponse, UpdateImageResponse,
UpdateImageArg, UpdateImageArg,
UpdateImageThunkConfig UpdateImageThunkConfig
>('api/imageUpdated', async (arg, { rejectWithValue }) => { >('thunkApi/imageUpdated', async (arg, { rejectWithValue }) => {
const { image_name, image_category, is_intermediate, session_id } = arg; const { image_name, image_category, is_intermediate, session_id } = arg;
const { patch } = $client.get(); const { patch } = $client.get();
const { data, error, response } = await patch('/api/v1/images/{image_name}', { const { data, error, response } = await patch('/api/v1/images/{image_name}', {
@ -277,6 +275,7 @@ type ListImagesThunkConfig = {
error: unknown; error: unknown;
}; };
}; };
/** /**
* `ImagesService.listImagesWithMetadata()` thunk * `ImagesService.listImagesWithMetadata()` thunk
*/ */
@ -284,46 +283,156 @@ export const receivedPageOfImages = createAppAsyncThunk<
ListImagesResponse, ListImagesResponse,
ListImagesArg, ListImagesArg,
ListImagesThunkConfig ListImagesThunkConfig
>('api/receivedPageOfImages', async (arg, { getState, rejectWithValue }) => { >(
const { get } = $client.get(); 'thunkApi/receivedPageOfImages',
async (arg, { getState, rejectWithValue }) => {
const { get } = $client.get();
const state = getState(); const state = getState();
const { categories, selectedBoardId } = state.gallery; const { categories, selectedBoardId } = state.gallery;
const images = selectImagesAll(state).filter((i) => { const images = selectImagesAll(state).filter((i) => {
const isInCategory = categories.includes(i.image_category); const isInCategory = categories.includes(i.image_category);
const isInSelectedBoard = selectedBoardId const isInSelectedBoard = selectedBoardId
? i.board_id === selectedBoardId ? i.board_id === selectedBoardId
: true; : true;
return isInCategory && isInSelectedBoard; return isInCategory && isInSelectedBoard;
}); });
let query: ListImagesArg = {}; let query: ListImagesArg = {};
if (size(arg)) { if (size(arg)) {
query = { query = {
...DEFAULT_IMAGES_LISTED_ARG, ...DEFAULT_IMAGES_LISTED_ARG,
offset: images.length, offset: images.length,
...arg, ...arg,
}; };
} else { } else {
query = { query = {
...DEFAULT_IMAGES_LISTED_ARG, ...DEFAULT_IMAGES_LISTED_ARG,
categories, categories,
offset: images.length, offset: images.length,
}; };
}
const { data, error, response } = await get('/api/v1/images/', {
params: {
query,
},
querySerializer: (q) => queryString.stringify(q, { arrayFormat: 'none' }),
});
if (error) {
return rejectWithValue({ arg, error });
}
return data;
} }
);
const { data, error, response } = await get('/api/v1/images/', { export type ImagesLoadedArg = {
params: { board_id: 'all' | 'none' | (string & Record<never, never>);
query, view: 'images' | 'assets';
}, offset: number;
querySerializer: (q) => queryString.stringify(q, { arrayFormat: 'none' }), limit?: number;
}); };
if (error) { type ImagesLoadedThunkConfig = {
return rejectWithValue({ arg, error }); rejectValue: {
arg: ImagesLoadedArg;
error: unknown;
};
};
const getCategories = (view: 'images' | 'assets'): ImageCategory[] => {
if (view === 'images') {
return ['general'];
} }
return ['control', 'mask', 'user', 'other'];
};
return data; const getBoardId = (
}); board_id: 'all' | 'none' | (string & Record<never, never>)
) => {
if (board_id === 'all') {
return undefined;
}
if (board_id === 'none') {
return 'none';
}
return board_id;
};
/**
* `ImagesService.listImagesWithMetadata()` thunk
*/
export const imagesLoaded = createAppAsyncThunk<
OffsetPaginatedResults_ImageDTO_,
ImagesLoadedArg,
ImagesLoadedThunkConfig
>(
'thunkApi/imagesLoaded',
async (arg, { getState, rejectWithValue, requestId }) => {
const { get } = $client.get();
// TODO: do not make request if request in progress
const query = {
categories: getCategories(arg.view),
board_id: getBoardId(arg.board_id),
offset: arg.offset,
limit: arg.limit ?? IMAGES_PER_PAGE,
};
const { data, error, response } = await get('/api/v1/images/', {
params: {
query,
},
querySerializer: (q) => queryString.stringify(q, { arrayFormat: 'none' }),
});
if (error) {
return rejectWithValue({ arg, error });
}
return data;
}
);
type GetImagesByNamesArg = NonNullable<
paths['/api/v1/images/']['post']['requestBody']['content']['application/json']
>;
type GetImagesByNamesResponse =
paths['/api/v1/images/']['post']['responses']['200']['content']['application/json'];
type GetImagesByNamesThunkConfig = {
rejectValue: {
arg: GetImagesByNamesArg;
error: unknown;
};
};
/**
* `ImagesService.GetImagesByNamesWithMetadata()` thunk
*/
export const receivedListOfImages = createAppAsyncThunk<
GetImagesByNamesResponse,
GetImagesByNamesArg,
GetImagesByNamesThunkConfig
>(
'thunkApi/receivedListOfImages',
async (arg, { getState, rejectWithValue }) => {
const { post } = $client.get();
const { data, error, response } = await post('/api/v1/images/', {
body: arg,
});
if (error) {
return rejectWithValue({ arg, error });
}
return data;
}
);

View File

@ -5,8 +5,13 @@ const subtext = defineStyle((props) => ({
color: mode('colors.base.500', 'colors.base.400')(props), color: mode('colors.base.500', 'colors.base.400')(props),
})); }));
const destructive = defineStyle((props) => ({
color: mode('colors.error.600', 'colors.error.300')(props),
}));
export const textTheme = defineStyleConfig({ export const textTheme = defineStyleConfig({
variants: { variants: {
subtext, subtext,
destructive,
}, },
}); });

View File

@ -1175,14 +1175,14 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz#8cfaf2ff603e9aabb910e9c0558c26cf32744061" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz#8cfaf2ff603e9aabb910e9c0558c26cf32744061"
integrity sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA== integrity sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==
"@eslint-community/eslint-utils@^4.2.0": "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.3.0":
version "4.4.0" version "4.4.0"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59"
integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA== integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==
dependencies: dependencies:
eslint-visitor-keys "^3.3.0" eslint-visitor-keys "^3.3.0"
"@eslint-community/regexpp@^4.4.0": "@eslint-community/regexpp@^4.4.0", "@eslint-community/regexpp@^4.5.0":
version "4.5.1" version "4.5.1"
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.5.1.tgz#cdd35dce4fa1a89a4fd42b1599eb35b3af408884" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.5.1.tgz#cdd35dce4fa1a89a4fd42b1599eb35b3af408884"
integrity sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ== integrity sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==
@ -1982,7 +1982,7 @@
resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-2.2.7.tgz#226a9e31680835a6188e887f3988e60c04d3f6a3" resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-2.2.7.tgz#226a9e31680835a6188e887f3988e60c04d3f6a3"
integrity sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA== integrity sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==
"@types/json-schema@*", "@types/json-schema@^7.0.6", "@types/json-schema@^7.0.9": "@types/json-schema@*", "@types/json-schema@^7.0.11", "@types/json-schema@^7.0.6":
version "7.0.12" version "7.0.12"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.12.tgz#d70faba7039d5fca54c83c7dbab41051d2b6f6cb" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.12.tgz#d70faba7039d5fca54c83c7dbab41051d2b6f6cb"
integrity sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA== integrity sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==
@ -2086,49 +2086,53 @@
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.2.tgz#ede1d1b1e451548d44919dc226253e32a6952c4b" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.2.tgz#ede1d1b1e451548d44919dc226253e32a6952c4b"
integrity sha512-kNnC1GFBLuhImSnV7w4njQkUiJi0ZXUycu1rUaouPqiKlXkh77JKgdRnTAp1x5eBwcIwbtI+3otwzuIDEuDoxQ== integrity sha512-kNnC1GFBLuhImSnV7w4njQkUiJi0ZXUycu1rUaouPqiKlXkh77JKgdRnTAp1x5eBwcIwbtI+3otwzuIDEuDoxQ==
"@typescript-eslint/eslint-plugin@^5.60.0": "@typescript-eslint/eslint-plugin@^6.0.0":
version "5.60.0" version "6.0.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.60.0.tgz#2f4bea6a3718bed2ba52905358d0f45cd3620d31" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.0.0.tgz#19ff4f1cab8d6f8c2c1825150f7a840bc5d9bdc4"
integrity sha512-78B+anHLF1TI8Jn/cD0Q00TBYdMgjdOn980JfAVa9yw5sop8nyTfVOQAv6LWywkOGLclDBtv5z3oxN4w7jxyNg== integrity sha512-xuv6ghKGoiq856Bww/yVYnXGsKa588kY3M0XK7uUW/3fJNNULKRfZfSBkMTSpqGG/8ZCXCadfh8G/z/B4aqS/A==
dependencies: dependencies:
"@eslint-community/regexpp" "^4.4.0" "@eslint-community/regexpp" "^4.5.0"
"@typescript-eslint/scope-manager" "5.60.0" "@typescript-eslint/scope-manager" "6.0.0"
"@typescript-eslint/type-utils" "5.60.0" "@typescript-eslint/type-utils" "6.0.0"
"@typescript-eslint/utils" "5.60.0" "@typescript-eslint/utils" "6.0.0"
"@typescript-eslint/visitor-keys" "6.0.0"
debug "^4.3.4" debug "^4.3.4"
grapheme-splitter "^1.0.4" grapheme-splitter "^1.0.4"
ignore "^5.2.0" graphemer "^1.4.0"
ignore "^5.2.4"
natural-compare "^1.4.0"
natural-compare-lite "^1.4.0" natural-compare-lite "^1.4.0"
semver "^7.3.7" semver "^7.5.0"
tsutils "^3.21.0" ts-api-utils "^1.0.1"
"@typescript-eslint/parser@^5.60.0": "@typescript-eslint/parser@^6.0.0":
version "5.60.0" version "6.0.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.60.0.tgz#08f4daf5fc6548784513524f4f2f359cebb4068a" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.0.0.tgz#46b2600fd1f67e62fc00a28093a75f41bf7effc4"
integrity sha512-jBONcBsDJ9UoTWrARkRRCgDz6wUggmH5RpQVlt7BimSwaTkTjwypGzKORXbR4/2Hqjk9hgwlon2rVQAjWNpkyQ== integrity sha512-TNaufYSPrr1U8n+3xN+Yp9g31vQDJqhXzzPSHfQDLcaO4tU+mCfODPxCwf4H530zo7aUBE3QIdxCXamEnG04Tg==
dependencies: dependencies:
"@typescript-eslint/scope-manager" "5.60.0" "@typescript-eslint/scope-manager" "6.0.0"
"@typescript-eslint/types" "5.60.0" "@typescript-eslint/types" "6.0.0"
"@typescript-eslint/typescript-estree" "5.60.0" "@typescript-eslint/typescript-estree" "6.0.0"
"@typescript-eslint/visitor-keys" "6.0.0"
debug "^4.3.4" debug "^4.3.4"
"@typescript-eslint/scope-manager@5.60.0": "@typescript-eslint/scope-manager@6.0.0":
version "5.60.0" version "6.0.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.60.0.tgz#ae511967b4bd84f1d5e179bb2c82857334941c1c" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.0.0.tgz#8ede47a37cb2b7ed82d329000437abd1113b5e11"
integrity sha512-hakuzcxPwXi2ihf9WQu1BbRj1e/Pd8ZZwVTG9kfbxAMZstKz8/9OoexIwnmLzShtsdap5U/CoQGRCWlSuPbYxQ== integrity sha512-o4q0KHlgCZTqjuaZ25nw5W57NeykZT9LiMEG4do/ovwvOcPnDO1BI5BQdCsUkjxFyrCL0cSzLjvIMfR9uo7cWg==
dependencies: dependencies:
"@typescript-eslint/types" "5.60.0" "@typescript-eslint/types" "6.0.0"
"@typescript-eslint/visitor-keys" "5.60.0" "@typescript-eslint/visitor-keys" "6.0.0"
"@typescript-eslint/type-utils@5.60.0": "@typescript-eslint/type-utils@6.0.0":
version "5.60.0" version "6.0.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.60.0.tgz#69b09087eb12d7513d5b07747e7d47f5533aa228" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-6.0.0.tgz#0478d8a94f05e51da2877cc0500f1b3c27ac7e18"
integrity sha512-X7NsRQddORMYRFH7FWo6sA9Y/zbJ8s1x1RIAtnlj6YprbToTiQnM6vxcMu7iYhdunmoC0rUWlca13D5DVHkK2g== integrity sha512-ah6LJvLgkoZ/pyJ9GAdFkzeuMZ8goV6BH7eC9FPmojrnX9yNCIsfjB+zYcnex28YO3RFvBkV6rMV6WpIqkPvoQ==
dependencies: dependencies:
"@typescript-eslint/typescript-estree" "5.60.0" "@typescript-eslint/typescript-estree" "6.0.0"
"@typescript-eslint/utils" "5.60.0" "@typescript-eslint/utils" "6.0.0"
debug "^4.3.4" debug "^4.3.4"
tsutils "^3.21.0" ts-api-utils "^1.0.1"
"@typescript-eslint/types@4.33.0": "@typescript-eslint/types@4.33.0":
version "4.33.0" version "4.33.0"
@ -2140,18 +2144,23 @@
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.60.0.tgz#3179962b28b4790de70e2344465ec97582ce2558" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.60.0.tgz#3179962b28b4790de70e2344465ec97582ce2558"
integrity sha512-ascOuoCpNZBccFVNJRSC6rPq4EmJ2NkuoKnd6LDNyAQmdDnziAtxbCGWCbefG1CNzmDvd05zO36AmB7H8RzKPA== integrity sha512-ascOuoCpNZBccFVNJRSC6rPq4EmJ2NkuoKnd6LDNyAQmdDnziAtxbCGWCbefG1CNzmDvd05zO36AmB7H8RzKPA==
"@typescript-eslint/typescript-estree@5.60.0", "@typescript-eslint/typescript-estree@^5.55.0": "@typescript-eslint/types@6.0.0":
version "5.60.0" version "6.0.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.60.0.tgz#4ddf1a81d32a850de66642d9b3ad1e3254fb1600" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.0.0.tgz#19795f515f8decbec749c448b0b5fc76d82445a1"
integrity sha512-R43thAuwarC99SnvrBmh26tc7F6sPa2B3evkXp/8q954kYL6Ro56AwASYWtEEi+4j09GbiNAHqYwNNZuNlARGQ== integrity sha512-Zk9KDggyZM6tj0AJWYYKgF0yQyrcnievdhG0g5FqyU3Y2DRxJn4yWY21sJC0QKBckbsdKKjYDV2yVrrEvuTgxg==
"@typescript-eslint/typescript-estree@6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.0.0.tgz#1e09aab7320e404fb9f83027ea568ac24e372f81"
integrity sha512-2zq4O7P6YCQADfmJ5OTDQTP3ktajnXIRrYAtHM9ofto/CJZV3QfJ89GEaM2BNGeSr1KgmBuLhEkz5FBkS2RQhQ==
dependencies: dependencies:
"@typescript-eslint/types" "5.60.0" "@typescript-eslint/types" "6.0.0"
"@typescript-eslint/visitor-keys" "5.60.0" "@typescript-eslint/visitor-keys" "6.0.0"
debug "^4.3.4" debug "^4.3.4"
globby "^11.1.0" globby "^11.1.0"
is-glob "^4.0.3" is-glob "^4.0.3"
semver "^7.3.7" semver "^7.5.0"
tsutils "^3.21.0" ts-api-utils "^1.0.1"
"@typescript-eslint/typescript-estree@^4.33.0": "@typescript-eslint/typescript-estree@^4.33.0":
version "4.33.0" version "4.33.0"
@ -2166,19 +2175,32 @@
semver "^7.3.5" semver "^7.3.5"
tsutils "^3.21.0" tsutils "^3.21.0"
"@typescript-eslint/utils@5.60.0": "@typescript-eslint/typescript-estree@^5.55.0":
version "5.60.0" version "5.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.60.0.tgz#4667c5aece82f9d4f24a667602f0f300864b554c" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.60.0.tgz#4ddf1a81d32a850de66642d9b3ad1e3254fb1600"
integrity sha512-ba51uMqDtfLQ5+xHtwlO84vkdjrqNzOnqrnwbMHMRY8Tqeme8C2Q8Fc7LajfGR+e3/4LoYiWXUM6BpIIbHJ4hQ== integrity sha512-R43thAuwarC99SnvrBmh26tc7F6sPa2B3evkXp/8q954kYL6Ro56AwASYWtEEi+4j09GbiNAHqYwNNZuNlARGQ==
dependencies: dependencies:
"@eslint-community/eslint-utils" "^4.2.0"
"@types/json-schema" "^7.0.9"
"@types/semver" "^7.3.12"
"@typescript-eslint/scope-manager" "5.60.0"
"@typescript-eslint/types" "5.60.0" "@typescript-eslint/types" "5.60.0"
"@typescript-eslint/typescript-estree" "5.60.0" "@typescript-eslint/visitor-keys" "5.60.0"
eslint-scope "^5.1.1" debug "^4.3.4"
globby "^11.1.0"
is-glob "^4.0.3"
semver "^7.3.7" semver "^7.3.7"
tsutils "^3.21.0"
"@typescript-eslint/utils@6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-6.0.0.tgz#27a16d0d8f2719274a39417b9782f7daa3802db0"
integrity sha512-SOr6l4NB6HE4H/ktz0JVVWNXqCJTOo/mHnvIte1ZhBQ0Cvd04x5uKZa3zT6tiodL06zf5xxdK8COiDvPnQ27JQ==
dependencies:
"@eslint-community/eslint-utils" "^4.3.0"
"@types/json-schema" "^7.0.11"
"@types/semver" "^7.3.12"
"@typescript-eslint/scope-manager" "6.0.0"
"@typescript-eslint/types" "6.0.0"
"@typescript-eslint/typescript-estree" "6.0.0"
eslint-scope "^5.1.1"
semver "^7.5.0"
"@typescript-eslint/visitor-keys@4.33.0": "@typescript-eslint/visitor-keys@4.33.0":
version "4.33.0" version "4.33.0"
@ -2196,6 +2218,14 @@
"@typescript-eslint/types" "5.60.0" "@typescript-eslint/types" "5.60.0"
eslint-visitor-keys "^3.3.0" eslint-visitor-keys "^3.3.0"
"@typescript-eslint/visitor-keys@6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.0.0.tgz#0b49026049fbd096d2c00c5e784866bc69532a31"
integrity sha512-cvJ63l8c0yXdeT5POHpL0Q1cZoRcmRKFCtSjNGJxPkcP571EfZMcNbzWAc7oK3D1dRzm/V5EwtkANTZxqvuuUA==
dependencies:
"@typescript-eslint/types" "6.0.0"
eslint-visitor-keys "^3.4.1"
"@vitejs/plugin-react-swc@^3.3.2": "@vitejs/plugin-react-swc@^3.3.2":
version "3.3.2" version "3.3.2"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react-swc/-/plugin-react-swc-3.3.2.tgz#34a82c1728066f48a86dfecb2f15df60f89207fb" resolved "https://registry.yarnpkg.com/@vitejs/plugin-react-swc/-/plugin-react-swc-3.3.2.tgz#34a82c1728066f48a86dfecb2f15df60f89207fb"
@ -3426,7 +3456,7 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz#c22c48f48942d08ca824cc526211ae400478a994" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz#c22c48f48942d08ca824cc526211ae400478a994"
integrity sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA== integrity sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==
eslint@^8.43.0: eslint@^8.44.0:
version "8.44.0" version "8.44.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.44.0.tgz#51246e3889b259bbcd1d7d736a0c10add4f0e500" resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.44.0.tgz#51246e3889b259bbcd1d7d736a0c10add4f0e500"
integrity sha512-0wpHoUbDUHgNCyvFB5aXLiQVfK9B0at6gUvzy83k4kAsQ/u769TQDX6iKC+aO4upIHO9WSaA3QoXYQDHbNwf1A== integrity sha512-0wpHoUbDUHgNCyvFB5aXLiQVfK9B0at6gUvzy83k4kAsQ/u769TQDX6iKC+aO4upIHO9WSaA3QoXYQDHbNwf1A==
@ -4069,7 +4099,7 @@ ieee754@^1.1.13:
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
ignore@^5.2.0: ignore@^5.2.0, ignore@^5.2.4:
version "5.2.4" version "5.2.4"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324"
integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ== integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==
@ -5795,6 +5825,13 @@ semver@^7.3.5, semver@^7.3.7:
dependencies: dependencies:
lru-cache "^6.0.0" lru-cache "^6.0.0"
semver@^7.5.0:
version "7.5.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
dependencies:
lru-cache "^6.0.0"
semver@~7.3.0: semver@~7.3.0:
version "7.3.8" version "7.3.8"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798"
@ -6235,6 +6272,11 @@ tree-kill@^1.2.2:
resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc"
integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==
ts-api-utils@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.0.1.tgz#8144e811d44c749cd65b2da305a032510774452d"
integrity sha512-lC/RGlPmwdrIBFTX59wwNzqh7aR2otPNPR/5brHZm/XKFYKsfqxihXUe9pU3JI+3vGkl+vyCoNNnPhJn3aLK1A==
ts-easing@^0.2.0: ts-easing@^0.2.0:
version "0.2.0" version "0.2.0"
resolved "https://registry.yarnpkg.com/ts-easing/-/ts-easing-0.2.0.tgz#c8a8a35025105566588d87dbda05dd7fbfa5a4ec" resolved "https://registry.yarnpkg.com/ts-easing/-/ts-easing-0.2.0.tgz#c8a8a35025105566588d87dbda05dd7fbfa5a4ec"
@ -6338,6 +6380,11 @@ typed-array-length@^1.0.4:
for-each "^0.3.3" for-each "^0.3.3"
is-typed-array "^1.1.9" is-typed-array "^1.1.9"
typescript-eslint@^0.0.1-alpha.0:
version "0.0.1-alpha.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-0.0.1-alpha.0.tgz#285d68a4e96588295cd436278801bcb6a6b916c1"
integrity sha512-1hNKM37dAWML/2ltRXupOq2uqcdRQyDFphl+341NTPXFLLLiDhErXx8VtaSLh3xP7SyHZdcCgpt9boYYVb3fQg==
typescript@^3.9.10, typescript@^3.9.7: typescript@^3.9.10, typescript@^3.9.7:
version "3.9.10" version "3.9.10"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8" resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8"
@ -6348,6 +6395,11 @@ typescript@^4.0.0, typescript@^4.9.5:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
typescript@^5.1.6:
version "5.1.6"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.6.tgz#02f8ac202b6dad2c0dd5e0913745b47a37998274"
integrity sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==
typescript@~5.0.4: typescript@~5.0.4:
version "5.0.4" version "5.0.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.4.tgz#b217fd20119bd61a94d4011274e0ab369058da3b" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.4.tgz#b217fd20119bd61a94d4011274e0ab369058da3b"