diff --git a/invokeai/app/api/routers/virtual_boards.py b/invokeai/app/api/routers/virtual_boards.py new file mode 100644 index 00000000000..f0c9e2edc51 --- /dev/null +++ b/invokeai/app/api/routers/virtual_boards.py @@ -0,0 +1,56 @@ +from fastapi import HTTPException, Path, Query +from fastapi.routing import APIRouter + +from invokeai.app.api.auth_dependencies import CurrentUserOrDefault +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.services.image_records.image_records_common import ImageCategory, ImageNamesResult +from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection +from invokeai.app.services.virtual_boards.virtual_boards_common import VirtualSubBoardDTO + +virtual_boards_router = APIRouter(prefix="/v1/virtual_boards", tags=["virtual_boards"]) + + +@virtual_boards_router.get( + "/by_date", + operation_id="list_virtual_boards_by_date", + response_model=list[VirtualSubBoardDTO], +) +async def list_virtual_boards_by_date( + current_user: CurrentUserOrDefault, +) -> list[VirtualSubBoardDTO]: + """Gets a list of virtual sub-boards grouped by date.""" + try: + return ApiDependencies.invoker.services.image_records.get_image_dates( + user_id=current_user.user_id, + is_admin=current_user.is_admin, + ) + except Exception: + raise HTTPException(status_code=500, detail="Failed to get virtual boards by date") + + +@virtual_boards_router.get( + "/by_date/{date}/image_names", + operation_id="list_virtual_board_image_names_by_date", + response_model=ImageNamesResult, +) +async def list_virtual_board_image_names_by_date( + current_user: CurrentUserOrDefault, + date: str = Path(description="The ISO date string, e.g. '2026-03-18'"), + starred_first: bool = Query(default=True, description="Whether to sort starred images first"), + order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The sort direction"), + categories: list[ImageCategory] | None = Query(default=None, description="The categories of images to include"), + search_term: str | None = Query(default=None, description="Search term to filter images"), +) -> ImageNamesResult: + """Gets ordered image names for a specific date.""" + try: + return ApiDependencies.invoker.services.image_records.get_image_names_by_date( + date=date, + starred_first=starred_first, + order_dir=order_dir, + categories=categories, + search_term=search_term, + user_id=current_user.user_id, + is_admin=current_user.is_admin, + ) + except Exception: + raise HTTPException(status_code=500, detail="Failed to get image names for date") diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py index 49894dba3cc..664c3078ca0 100644 --- a/invokeai/app/api_app.py +++ b/invokeai/app/api_app.py @@ -29,6 +29,7 @@ session_queue, style_presets, utilities, + virtual_boards, workflows, ) from invokeai.app.api.sockets import SocketIO @@ -131,6 +132,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint): app.include_router(images.images_router, prefix="/api") app.include_router(boards.boards_router, prefix="/api") app.include_router(board_images.board_images_router, prefix="/api") +app.include_router(virtual_boards.virtual_boards_router, prefix="/api") app.include_router(model_relationships.model_relationships_router, prefix="/api") app.include_router(app_info.app_router, prefix="/api") app.include_router(session_queue.session_queue_router, prefix="/api") diff --git a/invokeai/app/services/image_records/image_records_base.py b/invokeai/app/services/image_records/image_records_base.py index 16405c52708..79bda6aa67c 100644 --- a/invokeai/app/services/image_records/image_records_base.py +++ b/invokeai/app/services/image_records/image_records_base.py @@ -12,6 +12,7 @@ ) from invokeai.app.services.shared.pagination import OffsetPaginatedResults from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection +from invokeai.app.services.virtual_boards.virtual_boards_common import VirtualSubBoardDTO class ImageRecordStorageBase(ABC): @@ -117,3 +118,26 @@ def get_image_names( ) -> ImageNamesResult: """Gets ordered list of image names with metadata for optimistic updates.""" pass + + @abstractmethod + def get_image_dates( + self, + user_id: Optional[str] = None, + is_admin: bool = False, + ) -> list[VirtualSubBoardDTO]: + """Gets a list of dates with image counts, grouped by DATE(created_at).""" + pass + + @abstractmethod + def get_image_names_by_date( + self, + date: str, + starred_first: bool = True, + order_dir: SQLiteDirection = SQLiteDirection.Descending, + categories: Optional[list[ImageCategory]] = None, + search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, + ) -> ImageNamesResult: + """Gets ordered list of image names for a specific date.""" + pass diff --git a/invokeai/app/services/image_records/image_records_sqlite.py b/invokeai/app/services/image_records/image_records_sqlite.py index c6c237fc1e7..948d090823d 100644 --- a/invokeai/app/services/image_records/image_records_sqlite.py +++ b/invokeai/app/services/image_records/image_records_sqlite.py @@ -19,6 +19,7 @@ from invokeai.app.services.shared.pagination import OffsetPaginatedResults from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase +from invokeai.app.services.virtual_boards.virtual_boards_common import VirtualSubBoardDTO class SqliteImageRecordStorage(ImageRecordStorageBase): @@ -489,3 +490,141 @@ def get_image_names( image_names = [row[0] for row in result] return ImageNamesResult(image_names=image_names, starred_count=starred_count, total_count=len(image_names)) + + def get_image_dates( + self, + user_id: Optional[str] = None, + is_admin: bool = False, + ) -> list[VirtualSubBoardDTO]: + with self._db.transaction() as cursor: + query_conditions = "" + query_params: list[Union[int, str, bool]] = [] + + # Only non-intermediate images + query_conditions += """--sql + AND images.is_intermediate = 0 + """ + + # User isolation for non-admin users + if user_id is not None and not is_admin: + query_conditions += """--sql + AND images.user_id = ? + """ + query_params.append(user_id) + + query = f"""--sql + SELECT + DATE(images.created_at) as date, + SUM(CASE WHEN images.image_category = 'general' THEN 1 ELSE 0 END) as image_count, + SUM(CASE WHEN images.image_category != 'general' THEN 1 ELSE 0 END) as asset_count, + ( + SELECT i2.image_name FROM images i2 + WHERE DATE(i2.created_at) = DATE(images.created_at) + AND i2.is_intermediate = 0 + ORDER BY i2.created_at DESC LIMIT 1 + ) as cover_image_name + FROM images + WHERE 1=1 + {query_conditions} + GROUP BY DATE(images.created_at) + ORDER BY date DESC; + """ + + cursor.execute(query, query_params) + result = cast(list[sqlite3.Row], cursor.fetchall()) + + return [ + VirtualSubBoardDTO( + virtual_board_id=f"by_date:{dict(row)['date']}", + board_name=dict(row)["date"], + date=dict(row)["date"], + image_count=dict(row)["image_count"], + asset_count=dict(row)["asset_count"], + cover_image_name=dict(row)["cover_image_name"], + ) + for row in result + ] + + def get_image_names_by_date( + self, + date: str, + starred_first: bool = True, + order_dir: SQLiteDirection = SQLiteDirection.Descending, + categories: Optional[list[ImageCategory]] = None, + search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, + ) -> ImageNamesResult: + with self._db.transaction() as cursor: + query_conditions = "" + query_params: list[Union[int, str, bool]] = [] + + # Filter by date + query_conditions += """--sql + AND DATE(images.created_at) = ? + """ + query_params.append(date) + + # Only non-intermediate images + query_conditions += """--sql + AND images.is_intermediate = 0 + """ + + if categories is not None: + category_strings = [c.value for c in set(categories)] + placeholders = ",".join("?" * len(category_strings)) + query_conditions += f"""--sql + AND images.image_category IN ( {placeholders} ) + """ + for c in category_strings: + query_params.append(c) + + # User isolation for non-admin users + if user_id is not None and not is_admin: + query_conditions += """--sql + AND images.user_id = ? + """ + query_params.append(user_id) + + if search_term: + query_conditions += """--sql + AND ( + images.metadata LIKE ? + OR images.created_at LIKE ? + ) + """ + query_params.append(f"%{search_term.lower()}%") + query_params.append(f"%{search_term.lower()}%") + + # Get starred count if starred_first is enabled + starred_count = 0 + if starred_first: + starred_count_query = f"""--sql + SELECT COUNT(*) + FROM images + WHERE images.starred = TRUE AND (1=1{query_conditions}) + """ + cursor.execute(starred_count_query, query_params) + starred_count = cast(int, cursor.fetchone()[0]) + + # Get all image names with proper ordering + if starred_first: + names_query = f"""--sql + SELECT images.image_name + FROM images + WHERE 1=1{query_conditions} + ORDER BY images.starred DESC, images.created_at {order_dir.value} + """ + else: + names_query = f"""--sql + SELECT images.image_name + FROM images + WHERE 1=1{query_conditions} + ORDER BY images.created_at {order_dir.value} + """ + + cursor.execute(names_query, query_params) + result = cast(list[sqlite3.Row], cursor.fetchall()) + image_names = [row[0] for row in result] + + return ImageNamesResult(image_names=image_names, starred_count=starred_count, total_count=len(image_names)) diff --git a/invokeai/app/services/virtual_boards/__init__.py b/invokeai/app/services/virtual_boards/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/invokeai/app/services/virtual_boards/virtual_boards_common.py b/invokeai/app/services/virtual_boards/virtual_boards_common.py new file mode 100644 index 00000000000..e1df5a81ca5 --- /dev/null +++ b/invokeai/app/services/virtual_boards/virtual_boards_common.py @@ -0,0 +1,14 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class VirtualSubBoardDTO(BaseModel): + """A virtual sub-board computed from image metadata, not stored in the database.""" + + virtual_board_id: str = Field(description="The virtual board ID, e.g. 'by_date:2026-03-18'.") + board_name: str = Field(description="The display name of the virtual sub-board, e.g. '2026-03-18'.") + date: str = Field(description="The ISO date string, e.g. '2026-03-18'.") + image_count: int = Field(description="The number of general images for this date.") + asset_count: int = Field(description="The number of asset images for this date.") + cover_image_name: Optional[str] = Field(default=None, description="The most recent image name for this date.") diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx index 2d37a03f69f..c05a2df84fa 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx @@ -14,6 +14,7 @@ import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; import AddBoardButton from './AddBoardButton'; import GalleryBoard from './GalleryBoard'; import NoBoardBoard from './NoBoardBoard'; +import { VirtualBoardSection } from './VirtualBoardSection'; export const BoardsList = memo(() => { const { t } = useTranslation(); @@ -40,6 +41,7 @@ export const BoardsList = memo(() => { if (!boardSearchText.length) { elements.push(); + elements.push(); } filteredBoards.forEach((board) => { diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/VirtualBoardItem.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/VirtualBoardItem.tsx new file mode 100644 index 00000000000..d85c90f7dc1 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/VirtualBoardItem.tsx @@ -0,0 +1,96 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Box, Flex, Icon, Image, Text, Tooltip } from '@invoke-ai/ui-library'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { selectSelectedBoardId } from 'features/gallery/store/gallerySelectors'; +import { boardIdSelected } from 'features/gallery/store/gallerySlice'; +import { memo, useCallback } from 'react'; +import { PiCalendarBold, PiImageSquare } from 'react-icons/pi'; +import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import type { VirtualSubBoard } from 'services/api/endpoints/virtual_boards'; + +const _hover: SystemStyleObject = { + bg: 'base.850', +}; + +interface VirtualBoardItemProps { + board: VirtualSubBoard; +} + +const VirtualBoardItem = ({ board }: VirtualBoardItemProps) => { + const dispatch = useAppDispatch(); + const selectedBoardId = useAppSelector(selectSelectedBoardId); + const isSelected = selectedBoardId === board.virtual_board_id; + + const onClick = useCallback(() => { + if (selectedBoardId !== board.virtual_board_id) { + dispatch(boardIdSelected({ boardId: board.virtual_board_id })); + } + }, [selectedBoardId, board.virtual_board_id, dispatch]); + + return ( + + + + + + + {board.board_name} + + + + + + {board.image_count} | {board.asset_count} + + + + + + ); +}; + +export default memo(VirtualBoardItem); + +const CoverImage = ({ coverImageName }: { coverImageName: string | null }) => { + const { currentData: coverImage } = useGetImageDTOQuery(coverImageName ?? skipToken); + + if (coverImage) { + return ( + + ); + } + + return ( + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/VirtualBoardSection.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/VirtualBoardSection.tsx new file mode 100644 index 00000000000..bdadaf77fda --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/VirtualBoardSection.tsx @@ -0,0 +1,62 @@ +import { Collapse, Flex, Icon, IconButton, Text } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { selectGallerySlice, virtualBoardsSectionOpenChanged } from 'features/gallery/store/gallerySlice'; +import { memo, useCallback } from 'react'; +import { PiCalendarBold, PiCaretDownBold, PiCaretRightBold } from 'react-icons/pi'; +import { useListVirtualBoardsByDateQuery } from 'services/api/endpoints/virtual_boards'; + +import VirtualBoardItem from './VirtualBoardItem'; + +const selectShowVirtualBoards = createSelector(selectGallerySlice, (gallery) => gallery.showVirtualBoards); +const selectVirtualBoardsSectionOpen = createSelector( + selectGallerySlice, + (gallery) => gallery.virtualBoardsSectionOpen +); + +export const VirtualBoardSection = memo(() => { + const dispatch = useAppDispatch(); + const showVirtualBoards = useAppSelector(selectShowVirtualBoards); + const isOpen = useAppSelector(selectVirtualBoardsSectionOpen); + + const { data: virtualBoards } = useListVirtualBoardsByDateQuery(undefined, { + skip: !showVirtualBoards, + }); + + const toggleOpen = useCallback(() => { + dispatch(virtualBoardsSectionOpenChanged(!isOpen)); + }, [dispatch, isOpen]); + + if (!showVirtualBoards || !virtualBoards?.length) { + return null; + } + + return ( + + + + + + By Date + + + : } + onClick={toggleOpen} + /> + + + + {virtualBoards.map((board) => ( + + ))} + + + + ); +}); + +VirtualBoardSection.displayName = 'VirtualBoardSection'; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsSettingsPopover.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsSettingsPopover.tsx index 3fef611f99b..814595e7f2e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsSettingsPopover.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsSettingsPopover.tsx @@ -13,6 +13,7 @@ import { import BoardAutoAddSelect from 'features/gallery/components/Boards/BoardAutoAddSelect'; import AutoAssignBoardCheckbox from 'features/gallery/components/GallerySettingsPopover/AutoAssignBoardCheckbox'; import ShowArchivedBoardsCheckbox from 'features/gallery/components/GallerySettingsPopover/ShowArchivedBoardsCheckbox'; +import ShowVirtualBoardsCheckbox from 'features/gallery/components/GallerySettingsPopover/ShowVirtualBoardsCheckbox'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiGearSixFill } from 'react-icons/pi'; @@ -47,6 +48,7 @@ export const BoardsSettingsPopover = memo(() => { + diff --git a/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover/ShowVirtualBoardsCheckbox.tsx b/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover/ShowVirtualBoardsCheckbox.tsx new file mode 100644 index 00000000000..29e3e7ab3ce --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover/ShowVirtualBoardsCheckbox.tsx @@ -0,0 +1,29 @@ +import { Checkbox, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { selectGallerySlice, showVirtualBoardsChanged } from 'features/gallery/store/gallerySlice'; +import type { ChangeEvent } from 'react'; +import { memo, useCallback } from 'react'; + +const selectShowVirtualBoards = createSelector(selectGallerySlice, (gallery) => gallery.showVirtualBoards); + +const ShowVirtualBoardsCheckbox = () => { + const dispatch = useAppDispatch(); + const showVirtualBoards = useAppSelector(selectShowVirtualBoards); + + const onChange = useCallback( + (e: ChangeEvent) => { + dispatch(showVirtualBoardsChanged(e.target.checked)); + }, + [dispatch] + ); + + return ( + + Virtual Boards + + + ); +}; + +export default memo(ShowVirtualBoardsCheckbox); diff --git a/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts index c81728a1b21..487c5609062 100644 --- a/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts +++ b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts @@ -1,21 +1,61 @@ +import { skipToken } from '@reduxjs/toolkit/query'; import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppSelector } from 'app/store/storeHooks'; -import { selectGetImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { selectGetImageNamesQueryArgs, selectSelectedBoardId } from 'features/gallery/store/gallerySelectors'; +import { getDateFromVirtualBoardId, isVirtualBoardId } from 'features/gallery/store/types'; import { useGetImageNamesQuery } from 'services/api/endpoints/images'; +import { useGetVirtualBoardImageNamesByDateQuery } from 'services/api/endpoints/virtual_boards'; import { useDebounce } from 'use-debounce'; -const getImageNamesQueryOptions = { +const selectFromResult = ({ + currentData, + isLoading, + isFetching, +}: { + currentData?: { image_names: string[] }; + isLoading: boolean; + isFetching: boolean; +}) => ({ + imageNames: currentData?.image_names ?? EMPTY_ARRAY, + isLoading, + isFetching, +}); + +const queryOptions = { refetchOnReconnect: true, - selectFromResult: ({ currentData, isLoading, isFetching }) => ({ - imageNames: currentData?.image_names ?? EMPTY_ARRAY, - isLoading, - isFetching, - }), -} satisfies Parameters[1]; + selectFromResult, +}; export const useGalleryImageNames = () => { + const selectedBoardId = useAppSelector(selectSelectedBoardId); const _queryArgs = useAppSelector(selectGetImageNamesQueryArgs); const [queryArgs] = useDebounce(_queryArgs, 300); - const { imageNames, isLoading, isFetching } = useGetImageNamesQuery(queryArgs, getImageNamesQueryOptions); - return { imageNames, isLoading, isFetching, queryArgs }; + const isVirtual = isVirtualBoardId(selectedBoardId); + + // Regular board query + const regularResult = useGetImageNamesQuery(isVirtual ? skipToken : queryArgs, queryOptions); + + // Virtual board query + const date = isVirtual ? getDateFromVirtualBoardId(selectedBoardId) : ''; + const virtualResult = useGetVirtualBoardImageNamesByDateQuery( + isVirtual + ? { + date, + categories: queryArgs.categories ?? undefined, + search_term: queryArgs.search_term || undefined, + order_dir: queryArgs.order_dir, + starred_first: queryArgs.starred_first, + } + : skipToken, + queryOptions + ); + + const result = isVirtual ? virtualResult : regularResult; + + return { + imageNames: result.imageNames, + isLoading: result.isLoading, + isFetching: result.isFetching, + queryArgs, + }; }; diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index 6a25caadce4..e4894b60766 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -12,6 +12,7 @@ import { type ComparisonMode, type GalleryState, type GalleryView, + isVirtualBoardId, type OrderDir, zGalleryState, } from './types'; @@ -33,6 +34,8 @@ const getInitialState = (): GalleryState => ({ comparisonMode: 'slider', comparisonFit: 'fill', shouldShowArchivedBoards: false, + showVirtualBoards: false, + virtualBoardsSectionOpen: true, boardsListOrderBy: 'created_at', boardsListOrderDir: 'DESC', }); @@ -103,6 +106,10 @@ const slice = createSlice({ state.autoAddBoardId = 'none'; return; } + // Virtual boards cannot be auto-add targets + if (isVirtualBoardId(action.payload)) { + return; + } state.autoAddBoardId = action.payload; }, galleryViewChanged: (state, action: PayloadAction) => { @@ -127,6 +134,17 @@ const slice = createSlice({ shouldShowArchivedBoardsChanged: (state, action: PayloadAction) => { state.shouldShowArchivedBoards = action.payload; }, + showVirtualBoardsChanged: (state, action: PayloadAction) => { + state.showVirtualBoards = action.payload; + // If virtual boards are hidden and a virtual board is selected, reset to 'none' + if (!action.payload && isVirtualBoardId(state.selectedBoardId)) { + state.selectedBoardId = 'none'; + state.selection = []; + } + }, + virtualBoardsSectionOpenChanged: (state, action: PayloadAction) => { + state.virtualBoardsSectionOpen = action.payload; + }, starredFirstChanged: (state, action: PayloadAction) => { state.starredFirst = action.payload; }, @@ -172,6 +190,8 @@ export const { orderDirChanged, starredFirstChanged, shouldShowArchivedBoardsChanged, + showVirtualBoardsChanged, + virtualBoardsSectionOpenChanged, searchTermChanged, boardsListOrderByChanged, boardsListOrderDirChanged, @@ -189,6 +209,13 @@ export const gallerySliceConfig: SliceConfig = { if (!('_version' in state)) { state._version = 1; } + // Add virtual boards fields if missing (added in virtual boards feature) + if (!('showVirtualBoards' in state)) { + state.showVirtualBoards = false; + } + if (!('virtualBoardsSectionOpen' in state)) { + state.virtualBoardsSectionOpen = true; + } return zGalleryState.parse(state); }, persistDenylist: ['selection', 'galleryView', 'imageToCompare'], diff --git a/invokeai/frontend/web/src/features/gallery/store/types.ts b/invokeai/frontend/web/src/features/gallery/store/types.ts index addeefe870f..c040e5834d7 100644 --- a/invokeai/frontend/web/src/features/gallery/store/types.ts +++ b/invokeai/frontend/web/src/features/gallery/store/types.ts @@ -35,8 +35,16 @@ export const zGalleryState = z.object({ comparisonMode: zComparisonMode, comparisonFit: zComparisonFit, shouldShowArchivedBoards: z.boolean(), + showVirtualBoards: z.boolean(), + virtualBoardsSectionOpen: z.boolean(), boardsListOrderBy: zBoardRecordOrderBy, boardsListOrderDir: zOrderDir, }); export type GalleryState = z.infer; + +const VIRTUAL_BOARD_ID_PREFIX = 'by_date:'; + +export const isVirtualBoardId = (id: string): boolean => id.startsWith(VIRTUAL_BOARD_ID_PREFIX); + +export const getDateFromVirtualBoardId = (id: string): string => id.replace(VIRTUAL_BOARD_ID_PREFIX, ''); diff --git a/invokeai/frontend/web/src/services/api/endpoints/virtual_boards.ts b/invokeai/frontend/web/src/services/api/endpoints/virtual_boards.ts new file mode 100644 index 00000000000..b450bf84436 --- /dev/null +++ b/invokeai/frontend/web/src/services/api/endpoints/virtual_boards.ts @@ -0,0 +1,56 @@ +import queryString from 'query-string'; +import type { ImageCategory } from 'services/api/types'; + +import type { ApiTagDescription } from '..'; +import { api, buildV1Url } from '..'; + +export type VirtualSubBoard = { + virtual_board_id: string; + board_name: string; + date: string; + image_count: number; + asset_count: number; + cover_image_name: string | null; +}; + +type ImageNamesResult = { + image_names: string[]; + starred_count: number; + total_count: number; +}; + +const buildVirtualBoardsUrl = (path: string = '') => buildV1Url(`virtual_boards/${path}`); + +const virtualBoardsApi = api.injectEndpoints({ + endpoints: (build) => ({ + listVirtualBoardsByDate: build.query({ + query: () => ({ + url: buildVirtualBoardsUrl('by_date'), + }), + providesTags: (): ApiTagDescription[] => ['VirtualBoards', 'FetchOnReconnect'], + }), + + getVirtualBoardImageNamesByDate: build.query< + ImageNamesResult, + { + date: string; + starred_first?: boolean; + order_dir?: 'ASC' | 'DESC'; + categories?: ImageCategory[]; + search_term?: string; + } + >({ + query: ({ date, ...params }) => ({ + url: buildVirtualBoardsUrl( + `by_date/${date}/image_names?${queryString.stringify(params, { arrayFormat: 'none', skipNull: true, skipEmptyString: true })}` + ), + }), + providesTags: (_result, _error, arg): ApiTagDescription[] => [ + { type: 'ImageNameList', id: `virtual_${arg.date}` }, + 'FetchOnReconnect', + ], + }), + }), +}); + +export const { useListVirtualBoardsByDateQuery, useGetVirtualBoardImageNamesByDateQuery } = virtualBoardsApi; diff --git a/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts b/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts index 5b741907662..eb847b6c93d 100644 --- a/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts +++ b/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts @@ -1,4 +1,5 @@ import type { BoardId } from 'features/gallery/store/types'; +import { getDateFromVirtualBoardId, isVirtualBoardId } from 'features/gallery/store/types'; import { t } from 'i18next'; import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; @@ -7,6 +8,9 @@ export const useBoardName = (board_id: BoardId) => { { include_archived: true }, { selectFromResult: ({ data }) => { + if (isVirtualBoardId(board_id)) { + return { boardName: getDateFromVirtualBoardId(board_id) }; + } const selectedBoard = data?.find((b) => b.board_id === board_id); const boardName = selectedBoard?.board_name || t('boards.uncategorized'); diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index 5be1aa2a67f..da69e451f25 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -59,6 +59,7 @@ const tagTypes = [ 'FetchOnReconnect', 'ClientState', 'UserList', + 'VirtualBoards', ] as const; export type ApiTagDescription = TagDescription<(typeof tagTypes)[number]>; export const LIST_TAG = 'LIST'; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index fc6506ce22b..879ecf5665a 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1420,6 +1420,46 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/virtual_boards/by_date": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Virtual Boards By Date + * @description Gets a list of virtual sub-boards grouped by date. + */ + get: operations["list_virtual_boards_by_date"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/virtual_boards/by_date/{date}/image_names": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Virtual Board Image Names By Date + * @description Gets ordered image names for a specific date. + */ + get: operations["list_virtual_board_image_names_by_date"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/model_relationships/i/{model_key}": { parameters: { query?: never; @@ -27580,6 +27620,42 @@ export type components = { /** Error Type */ type: string; }; + /** + * VirtualSubBoardDTO + * @description A virtual sub-board computed from image metadata, not stored in the database. + */ + VirtualSubBoardDTO: { + /** + * Virtual Board Id + * @description The virtual board ID, e.g. 'by_date:2026-03-18'. + */ + virtual_board_id: string; + /** + * Board Name + * @description The display name of the virtual sub-board, e.g. '2026-03-18'. + */ + board_name: string; + /** + * Date + * @description The ISO date string, e.g. '2026-03-18'. + */ + date: string; + /** + * Image Count + * @description The number of general images for this date. + */ + image_count: number; + /** + * Asset Count + * @description The number of asset images for this date. + */ + asset_count: number; + /** + * Cover Image Name + * @description The most recent image name for this date. + */ + cover_image_name?: string | null; + }; /** Workflow */ Workflow: { /** @@ -31601,6 +31677,67 @@ export interface operations { }; }; }; + list_virtual_boards_by_date: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VirtualSubBoardDTO"][]; + }; + }; + }; + }; + list_virtual_board_image_names_by_date: { + parameters: { + query?: { + /** @description Whether to sort starred images first */ + starred_first?: boolean; + /** @description The sort direction */ + order_dir?: components["schemas"]["SQLiteDirection"]; + /** @description The categories of images to include */ + categories?: components["schemas"]["ImageCategory"][] | null; + /** @description Search term to filter images */ + search_term?: string | null; + }; + header?: never; + path: { + /** @description The ISO date string, e.g. '2026-03-18' */ + date: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImageNamesResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_related_models: { parameters: { query?: never; diff --git a/invokeai/frontend/web/src/services/api/util/tagInvalidation.ts b/invokeai/frontend/web/src/services/api/util/tagInvalidation.ts index 477a5a03f87..bac3130d312 100644 --- a/invokeai/frontend/web/src/services/api/util/tagInvalidation.ts +++ b/invokeai/frontend/web/src/services/api/util/tagInvalidation.ts @@ -4,7 +4,7 @@ import { getListImagesUrl } from 'services/api/util'; import type { ApiTagDescription } from '..'; export const getTagsToInvalidateForBoardAffectingMutation = (affected_boards: string[]): ApiTagDescription[] => { - const tags: ApiTagDescription[] = ['ImageNameList']; + const tags: ApiTagDescription[] = ['ImageNameList', 'VirtualBoards']; for (const board_id of affected_boards) { tags.push({