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({