diff --git a/docs/features/gallery.md b/docs/features/gallery.md index 1c12f59c7a7..eb246b83b95 100644 --- a/docs/features/gallery.md +++ b/docs/features/gallery.md @@ -34,6 +34,26 @@ The settings button opens a list of options. Below these two buttons, you'll see the Search Boards text entry area. You use this to search for specific boards by the name of the board. Next to it is the Add Board (+) button which lets you add new boards. Boards can be renamed by clicking on the name of the board under its thumbnail and typing in the new name. +### Virtual Boards by Date + +In addition to the regular user-created boards, the Gallery can show **virtual boards** that group your images automatically by their creation date. Virtual boards are not stored in the database — they are computed on the fly from existing image metadata, so enabling or disabling them never moves or modifies your images. + +#### Enabling Virtual Boards + +Open the boards settings popover (the gear icon next to the boards search field) and toggle **Show Virtual Boards**. A new collapsible **By Date** section then appears in the boards list, with one entry per day on which images were generated (e.g. `2026-03-18`). + +Each virtual board entry shows: + +- a cover thumbnail (the most recent image of that day) +- the number of generated **images** on that date +- the number of uploaded **assets** on that date + +Selecting a virtual board filters the gallery to show only the images from that day. Search, category filters (Images / Assets), starred-first sorting and sort direction all work the same way as on regular boards. + +!!! note "Read-only" + + Virtual boards are a view over your existing images. You cannot rename, delete or auto-assign to them, and images cannot be "moved into" a virtual board — they appear there automatically based on their creation date. To organize images permanently, use regular boards. + ### Board Thumbnail Menu Each board has a context menu (ctrl+click / right-click). 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 2ca6746b496..110fd757bd1 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 @@ -177,6 +178,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 457cf2f4686..dd1e9fd4f37 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): @@ -122,3 +123,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 07126d53a9f..e88b49c56d3 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): @@ -503,3 +504,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 85a5d320a1a..56fa307dd25 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -60,6 +60,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 2e93e98ad56..5c50661657a 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1457,6 +1457,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; @@ -29402,6 +29442,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: { /** @@ -33498,6 +33574,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({