diff --git a/application/ui/src/features/annotator/annotation-actions-provider.component.tsx b/application/ui/src/features/annotator/annotation-actions-provider.component.tsx index 23cb8f978a..301eb04eeb 100644 --- a/application/ui/src/features/annotator/annotation-actions-provider.component.tsx +++ b/application/ui/src/features/annotator/annotation-actions-provider.component.tsx @@ -4,7 +4,7 @@ import { createContext, ReactNode, useContext, useEffect, useRef, useState } from 'react'; import { useProjectIdentifier } from 'hooks/use-project-identifier.hook'; -import { get } from 'lodash-es'; +import { get, isEmpty, isObject } from 'lodash-es'; import { $api } from 'src/api/client'; import { components } from 'src/api/openapi-spec'; import { v4 as uuid } from 'uuid'; @@ -36,6 +36,7 @@ interface AnnotationsContextValue { deleteAnnotations: (annotationIds: string[]) => void; updateAnnotations: (updatedAnnotations: Annotation[]) => void; submitAnnotations: () => Promise; + isUserReviewed: boolean; isSaving: boolean; } @@ -46,19 +47,30 @@ type AnnotationActionsProviderProps = { mediaItem: DatasetItem; }; +const isUnannotatedError = (error: unknown): boolean => { + return ( + isObject(error) && 'detail' in error && /Dataset item has not been annotated yet/i.test(String(error.detail)) + ); +}; + export const AnnotationActionsProvider = ({ children, mediaItem }: AnnotationActionsProviderProps) => { const projectId = useProjectIdentifier(); const saveMutation = $api.useMutation( 'post', '/api/projects/{project_id}/dataset/items/{dataset_item_id}/annotations' ); - const { data: serverAnnotations } = $api.useQuery( + + const { data: serverAnnotations, error: fetchError } = $api.useQuery( 'get', '/api/projects/{project_id}/dataset/items/{dataset_item_id}/annotations', { params: { path: { project_id: projectId, dataset_item_id: mediaItem.id || '' } }, + }, + { + retry: (_failureCount, error: unknown) => !isUnannotatedError(error), } ); + const { data: project } = $api.useQuery('get', '/api/projects/{project_id}', { params: { path: { project_id: projectId } }, }); @@ -120,9 +132,16 @@ export const AnnotationActionsProvider = ({ children, mediaItem }: AnnotationAct } }, [serverAnnotations, project]); + useEffect(() => { + if (!isEmpty(fetchError)) { + setLocalAnnotations([]); + } + }, [fetchError]); + return ( void; @@ -52,6 +54,7 @@ export const Gallery = ({ items, hasNextPage, isFetchingNextPage, fetchNextPage onSelectionChange={setSelectedKeys} contentItem={(item) => ( ( setSelectedMediaItem(null)}> {selectedMediaItem !== null && ( setSelectedMediaItem(null)} onSelectedMediaItem={setSelectedMediaItem} diff --git a/application/ui/src/features/dataset/gallery/gallery.module.scss b/application/ui/src/features/dataset/gallery/gallery.module.scss new file mode 100644 index 0000000000..3e5463cf1a --- /dev/null +++ b/application/ui/src/features/dataset/gallery/gallery.module.scss @@ -0,0 +1,5 @@ +.mediaItem { + [data-floating-container='true'] { + background: var(--spectrum-global-color-gray-50); + } +} diff --git a/application/ui/src/features/dataset/gallery/media-item.component.tsx b/application/ui/src/features/dataset/gallery/media-item.component.tsx index 3d7b81d608..e0813eef02 100644 --- a/application/ui/src/features/dataset/gallery/media-item.component.tsx +++ b/application/ui/src/features/dataset/gallery/media-item.component.tsx @@ -10,6 +10,7 @@ import { isFunction } from 'lodash-es'; import classes from './media-item.module.scss'; interface MediaItemProps { + className?: string; contentElement: () => ReactNode; topLeftElement?: () => ReactNode; topRightElement?: () => ReactNode; @@ -18,6 +19,7 @@ interface MediaItemProps { } export const MediaItem = ({ + className, contentElement, topLeftElement, topRightElement, @@ -25,29 +27,41 @@ export const MediaItem = ({ bottomRightElement, }: MediaItemProps) => { return ( - + {contentElement()} {isFunction(topLeftElement) && ( - + {topLeftElement()} )} {isFunction(topRightElement) && ( - + {topRightElement()} )} {isFunction(bottomLeftElement) && ( - + {bottomLeftElement()} )} {isFunction(bottomRightElement) && ( - + {bottomRightElement()} )} diff --git a/application/ui/src/features/dataset/gallery/media-item.module.scss b/application/ui/src/features/dataset/gallery/media-item.module.scss index 02cda58eb6..2fd1ce7b45 100644 --- a/application/ui/src/features/dataset/gallery/media-item.module.scss +++ b/application/ui/src/features/dataset/gallery/media-item.module.scss @@ -3,7 +3,6 @@ position: absolute; line-height: 0px; padding: var(--spectrum-global-dimension-size-125); - background: var(--spectrum-global-color-gray-50); border-radius: var(--spectrum-global-dimension-size-50); &:empty { diff --git a/application/ui/src/features/dataset/media-preview/annotator-buttons.component.tsx b/application/ui/src/features/dataset/media-preview/annotator-buttons.component.tsx deleted file mode 100644 index e4a070d8c0..0000000000 --- a/application/ui/src/features/dataset/media-preview/annotator-buttons.component.tsx +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (C) 2025 Intel Corporation -// SPDX-License-Identifier: Apache-2.0 - -import { Button, ButtonGroup } from '@geti/ui'; -import { useAnnotationActions } from 'src/features/annotator/annotation-actions-provider.component'; - -type AnnotatorButtonsProps = { - onClose: () => void; -}; - -export const AnnotatorButtons = ({ onClose }: AnnotatorButtonsProps) => { - const { submitAnnotations, isSaving } = useAnnotationActions(); - - return ( - - - - - ); -}; diff --git a/application/ui/src/features/dataset/media-preview/media-preview.component.tsx b/application/ui/src/features/dataset/media-preview/media-preview.component.tsx index f456a5b8c7..42f2c5e054 100644 --- a/application/ui/src/features/dataset/media-preview/media-preview.component.tsx +++ b/application/ui/src/features/dataset/media-preview/media-preview.component.tsx @@ -12,7 +12,7 @@ import { ZoomProvider } from '../../../components/zoom/zoom.provider'; import { AnnotatorCanvas } from '../../annotator/annotator-canvas'; import { SelectAnnotationProvider } from '../../annotator/select-annotation-provider.component'; import { DatasetItem } from '../../annotator/types'; -import { AnnotatorButtons } from './annotator-buttons.component'; +import { useGetDatasetItems } from '../gallery/use-get-dataset-items.hook'; import { ToolSelectionBar } from './primary-toolbar/primary-toolbar.component'; import { SecondaryToolbar } from './secondary-toolbar/secondary-toolbar.component'; import { SidebarItems } from './sidebar-items/sidebar-items.component'; @@ -30,6 +30,8 @@ const CanvasAreaLoading = () => ( ); export const MediaPreview = ({ mediaItem, close, onSelectedMediaItem }: MediaPreviewProps) => { + const { items, hasNextPage, isFetchingNextPage, fetchNextPage } = useGetDatasetItems(); + return ( Preview @@ -64,7 +66,12 @@ export const MediaPreview = ({ mediaItem, close, onSelectedMediaItem }: MediaPre - + @@ -75,11 +82,14 @@ export const MediaPreview = ({ mediaItem, close, onSelectedMediaItem }: MediaPre - - - - - + diff --git a/application/ui/src/features/dataset/media-preview/secondary-toolbar/secondary-toolbar.component.tsx b/application/ui/src/features/dataset/media-preview/secondary-toolbar/secondary-toolbar.component.tsx index 821ace1d9c..9417841e2e 100644 --- a/application/ui/src/features/dataset/media-preview/secondary-toolbar/secondary-toolbar.component.tsx +++ b/application/ui/src/features/dataset/media-preview/secondary-toolbar/secondary-toolbar.component.tsx @@ -1,24 +1,92 @@ // Copyright (C) 2025 Intel Corporation // SPDX-License-Identifier: Apache-2.0 -import { Flex, Grid } from '@geti/ui'; +import { Button, ButtonGroup, dimensionValue, Flex, Grid } from '@geti/ui'; +import { QueryClient, useQueryClient } from '@tanstack/react-query'; +import { isEmpty } from 'lodash-es'; +import { useAnnotationActions } from 'src/features/annotator/annotation-actions-provider.component'; +import { DatasetItem } from 'src/features/annotator/types'; +import { DeleteMediaItem } from '../../gallery/delete-media-item/delete-media-item.component'; import { LabelPicker } from './label-picker.component'; import { useSecondaryToolbarState } from './use-secondary-toolbar-state.hook'; import classes from '../media-preview.module.scss'; -export const SecondaryToolbar = () => { +type SecondaryToolbarProps = { + items: DatasetItem[]; + mediaItem: DatasetItem; + onClose: () => void; + onSelectedMediaItem: (item: DatasetItem) => void; +}; + +const getNextItem = (totalItems: number, newIndex: number) => { + return Math.min(totalItems, newIndex + 1); +}; + +const invalidateMediaItemAnnotations = (queryClient: QueryClient) => { + queryClient.invalidateQueries({ + queryKey: ['get', '/api/projects/{project_id}/dataset/items/{dataset_item_id}/annotations'], + }); +}; + +export const SecondaryToolbar = ({ items, mediaItem, onClose, onSelectedMediaItem }: SecondaryToolbarProps) => { + const queryClient = useQueryClient(); + const { annotations, isSaving, submitAnnotations } = useAnnotationActions(); const { isHidden, projectLabels, toggleLabels, annotationsToUpdate } = useSecondaryToolbarState(); const annotationLabelId = annotationsToUpdate.at(0)?.labels?.at(0)?.id; const selectedLabel = projectLabels.find((label) => label.id === annotationLabelId) ?? null; + const hasAnnotations = !isEmpty(annotations); + const selectedIndex = items.findIndex((item) => item.id === mediaItem.id); + + const handleSubmit = async () => { + await submitAnnotations(); + + const nextItem = getNextItem(items.length - 1, selectedIndex); + onSelectedMediaItem(items[nextItem]); + + const isLastItem = selectedIndex === items.length - 1; + isLastItem && invalidateMediaItemAnnotations(queryClient); + }; + + const handleDeleteItem = ([deletedItem]: string[], totalItems: number) => { + const deletedIndex = items.findIndex((item) => item.id === deletedItem); + const nextItem = getNextItem(totalItems - 1, deletedIndex); + + onSelectedMediaItem(items[nextItem]); + }; return ( - - - + + + + + + handleDeleteItem([deletedItem], items.length - 1)} + /> + + + + diff --git a/application/ui/src/features/dataset/media-preview/sidebar-items/sidebar-items.component.tsx b/application/ui/src/features/dataset/media-preview/sidebar-items/sidebar-items.component.tsx index 737ca0b99a..e6c196276f 100644 --- a/application/ui/src/features/dataset/media-preview/sidebar-items/sidebar-items.component.tsx +++ b/application/ui/src/features/dataset/media-preview/sidebar-items/sidebar-items.component.tsx @@ -1,18 +1,14 @@ // Copyright (C) 2025 Intel Corporation // SPDX-License-Identifier: Apache-2.0 -import { useRef, useState } from 'react'; +import { useRef } from 'react'; -import { Selection, Size, useUnwrapDOMRef, View } from '@geti/ui'; -import { useProjectIdentifier } from 'hooks/use-project-identifier.hook'; +import { Size, useUnwrapDOMRef, View } from '@geti/ui'; import { DatasetItem } from 'src/features/annotator/types'; import { useSelectedData } from 'src/routes/dataset/provider'; -import { MediaItem } from '../../gallery/media-item.component'; -import { MediaThumbnail } from '../../gallery/media-thumbnail.component'; -import { useGetDatasetItems } from '../../gallery/use-get-dataset-items.hook'; -import { getThumbnailUrl } from '../../gallery/utils'; import { VirtualizerGridLayout } from '../../virtualizer-grid-layout/virtualizer-grid-layout.component'; +import { SidebarMediaItem } from './sidebar-media-item.component'; import { useKeyboardNavigation } from './use-keyboard-navigation.hook'; const layoutOptions = { @@ -24,17 +20,25 @@ const layoutOptions = { }; type SidebarItemsProps = { + items: DatasetItem[]; + hasNextPage: boolean; + isFetchingNextPage: boolean; mediaItem: DatasetItem; + fetchNextPage: () => void; onSelectedMediaItem: (item: DatasetItem) => void; }; -export const SidebarItems = ({ mediaItem, onSelectedMediaItem }: SidebarItemsProps) => { +export const SidebarItems = ({ + mediaItem, + items, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + onSelectedMediaItem, +}: SidebarItemsProps) => { const ref = useRef(null); const unwrapRef = useUnwrapDOMRef(ref); - const project_id = useProjectIdentifier(); const { mediaState } = useSelectedData(); - const [selectedKeys, setSelectedKeys] = useState(new Set([String(mediaItem.id)])); - const { items, hasNextPage, isFetchingNextPage, fetchNextPage } = useGetDatasetItems(); const selectedIndex = items.findIndex((item) => item.id === mediaItem.id); @@ -42,10 +46,7 @@ export const SidebarItems = ({ mediaItem, onSelectedMediaItem }: SidebarItemsPro ref: unwrapRef, items, selectedIndex, - onSelectedMediaItem: (item) => { - onSelectedMediaItem(item); - setSelectedKeys(new Set([item.id])); - }, + onSelectedMediaItem, }); return ( @@ -55,21 +56,16 @@ export const SidebarItems = ({ mediaItem, onSelectedMediaItem }: SidebarItemsPro ariaLabel='sidebar-items' selectionMode='single' mediaState={mediaState} - selectedKeys={selectedKeys} + selectedKeys={new Set([String(mediaItem.id)])} layoutOptions={layoutOptions} isLoadingMore={isFetchingNextPage} scrollToIndex={selectedIndex} onLoadMore={() => hasNextPage && fetchNextPage()} - onSelectionChange={setSelectedKeys} contentItem={(item) => ( - ( - onSelectedMediaItem(item)} - /> - )} + )} /> diff --git a/application/ui/src/features/dataset/media-preview/sidebar-items/sidebar-media-item.component.tsx b/application/ui/src/features/dataset/media-preview/sidebar-items/sidebar-media-item.component.tsx new file mode 100644 index 0000000000..3f46dff91f --- /dev/null +++ b/application/ui/src/features/dataset/media-preview/sidebar-items/sidebar-media-item.component.tsx @@ -0,0 +1,48 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +import { Accept, Search } from '@geti/ui/icons'; +import { useProjectIdentifier } from 'hooks/use-project-identifier.hook'; +import { View } from 'packages/ui'; +import { useAnnotationActions } from 'src/features/annotator/annotation-actions-provider.component'; +import { DatasetItem } from 'src/features/annotator/types'; + +import { MediaItem } from '../../gallery/media-item.component'; +import { MediaThumbnail } from '../../gallery/media-thumbnail.component'; +import { getThumbnailUrl } from '../../gallery/utils'; + +import classes from './sidebar-media-item.module.scss'; + +type SidebarMediaItemProps = { + item: DatasetItem; + isSelected: boolean; + onSelectedMediaItem: (item: DatasetItem) => void; +}; + +export const SidebarMediaItem = ({ item, isSelected, onSelectedMediaItem }: SidebarMediaItemProps) => { + const project_id = useProjectIdentifier(); + const { isUserReviewed } = useAnnotationActions(); + + return ( + ( + onSelectedMediaItem(item)} + /> + )} + bottomRightElement={() => { + if (!isSelected) { + return null; + } + + return ( + + {isUserReviewed ? : } + + ); + }} + /> + ); +}; diff --git a/application/ui/src/features/dataset/media-preview/sidebar-items/sidebar-media-item.module.scss b/application/ui/src/features/dataset/media-preview/sidebar-items/sidebar-media-item.module.scss new file mode 100644 index 0000000000..0b0e4316c3 --- /dev/null +++ b/application/ui/src/features/dataset/media-preview/sidebar-items/sidebar-media-item.module.scss @@ -0,0 +1,14 @@ +div:has(> .iconAccept), +div:has(> .iconSearch) { + border-radius: var(--spectrum-global-dimension-size-200); + padding: var(--spectrum-global-dimension-size-100) var(--spectrum-global-dimension-size-200); +} + +div:has(> .iconAccept) { + background: var(--moss-tint-1); + color: var(--spectrum-global-color-gray-50); +} + +div:has(> .iconSearch) { + background: var(--coral-shade-1); +} diff --git a/application/ui/src/index.css b/application/ui/src/index.css index 516180cfe2..0ced7da6a5 100644 --- a/application/ui/src/index.css +++ b/application/ui/src/index.css @@ -17,6 +17,7 @@ --background: #313236; --background-inverse: #3c3e42; --coral: #ff5662; + --coral-shade-1: #c81326; --energy-blue-light: #7bdeff; --energy-blue: rgb(0, 199, 253); @@ -93,3 +94,8 @@ button { svg { fill: currentColor; } + +/* Increase the modal’s opaque background when a modal is opened inside another modal. */ +[data-testid='underlay'] { + z-index: 2 !important; +}