From 543ddf0563093de1f0cc812fa953560ca4d8345a Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 10 Sep 2025 10:31:16 +0200 Subject: [PATCH 01/13] fix: make sure default station passes validation --- ami/main/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ami/main/models.py b/ami/main/models.py index 88da476ae..d6fc59f39 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -119,6 +119,8 @@ def get_or_create_default_deployment( project=project, research_site=site, device=device, + latitude=0, + longitude=0, ) logger.info(f"Created default deployment for project {project}") return deployment From 936a83584b7fa807496beee584ef7b3413728500 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 10 Sep 2025 10:38:37 +0200 Subject: [PATCH 02/13] feat: setup dedicated capture view --- ui/src/app.tsx | 4 +- .../components/filtering/filter-control.tsx | 1 + ui/src/data-services/models/collection.ts | 3 +- .../capture-columns.tsx | 0 .../capture-gallery.tsx | 0 .../captures.tsx} | 77 +++++++++---------- ui/src/pages/job-details/job-details.tsx | 4 - ui/src/pages/jobs/jobs-columns.tsx | 19 +---- .../collections/collection-columns.tsx | 47 ++++++----- .../project/sidebar/useSidebarSections.tsx | 13 +++- ui/src/utils/constants.ts | 4 +- ui/src/utils/getAppRoute.ts | 1 + ui/src/utils/language.ts | 4 +- ui/src/utils/useFilters.ts | 4 + ui/src/utils/useNavItems.ts | 23 +++--- 15 files changed, 103 insertions(+), 101 deletions(-) rename ui/src/pages/{collection-details => captures}/capture-columns.tsx (100%) rename ui/src/pages/{collection-details => captures}/capture-gallery.tsx (100%) rename ui/src/pages/{collection-details/collection-details.tsx => captures/captures.tsx} (63%) diff --git a/ui/src/app.tsx b/ui/src/app.tsx index 86da36a17..cd12ce61e 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -17,7 +17,7 @@ import { Auth } from 'pages/auth/auth' import { Login } from 'pages/auth/login' import { ResetPassword } from 'pages/auth/reset-password' import { ResetPasswordConfirm } from 'pages/auth/reset-password-confirm' -import { CollectionDetails } from 'pages/collection-details/collection-details' +import { Captures } from 'pages/captures/captures' import { Deployments } from 'pages/deployments/deployments' import { Jobs } from 'pages/jobs/jobs' import { Occurrences } from 'pages/occurrences/occurrences' @@ -108,7 +108,6 @@ export const App = () => ( /> } /> } /> - } /> } /> ( } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/components/filtering/filter-control.tsx b/ui/src/components/filtering/filter-control.tsx index 7a92f1a19..6def74a19 100644 --- a/ui/src/components/filtering/filter-control.tsx +++ b/ui/src/components/filtering/filter-control.tsx @@ -26,6 +26,7 @@ const ComponentMap: { best_determination_score: ScoreFilter, classification_threshold: ScoreFilter, collection: CollectionFilter, + collections: CollectionFilter, date_end: DateFilter, date_start: DateFilter, deployment: StationFilter, diff --git a/ui/src/data-services/models/collection.ts b/ui/src/data-services/models/collection.ts index 3fc897262..89634fbce 100644 --- a/ui/src/data-services/models/collection.ts +++ b/ui/src/data-services/models/collection.ts @@ -80,7 +80,8 @@ export class Collection extends Entity { this.numImagesWithDetections && this.numImages ? (this.numImagesWithDetections / this.numImages) * 100 : 0 - return `${this.numImagesWithDetections?.toLocaleString()} / ${this.numImages?.toLocaleString()} (${pct.toFixed( + + return `${this.numImagesWithDetections?.toLocaleString()} (${pct.toFixed( 0 )}%)` } diff --git a/ui/src/pages/collection-details/capture-columns.tsx b/ui/src/pages/captures/capture-columns.tsx similarity index 100% rename from ui/src/pages/collection-details/capture-columns.tsx rename to ui/src/pages/captures/capture-columns.tsx diff --git a/ui/src/pages/collection-details/capture-gallery.tsx b/ui/src/pages/captures/capture-gallery.tsx similarity index 100% rename from ui/src/pages/collection-details/capture-gallery.tsx rename to ui/src/pages/captures/capture-gallery.tsx diff --git a/ui/src/pages/collection-details/collection-details.tsx b/ui/src/pages/captures/captures.tsx similarity index 63% rename from ui/src/pages/collection-details/collection-details.tsx rename to ui/src/pages/captures/captures.tsx index ac5f8f43a..0f152b917 100644 --- a/ui/src/pages/collection-details/collection-details.tsx +++ b/ui/src/pages/captures/captures.tsx @@ -1,5 +1,6 @@ +import { FilterControl } from 'components/filtering/filter-control' +import { FilterSection } from 'components/filtering/filter-section' import { useCaptures } from 'data-services/hooks/captures/useCaptures' -import { useCollectionDetails } from 'data-services/hooks/collections/useCollectionDetails' import { IconType } from 'design-system/components/icon/icon' import { PageFooter } from 'design-system/components/page-footer/page-footer' import { PageHeader } from 'design-system/components/page-header/page-header' @@ -10,40 +11,38 @@ import { ToggleGroup } from 'design-system/components/toggle-group/toggle-group' import { useState } from 'react' import { useParams } from 'react-router-dom' import { STRING, translate } from 'utils/language' +import { useFilters } from 'utils/useFilters' import { usePagination } from 'utils/usePagination' import { useSelectedView } from 'utils/useSelectedView' import { columns } from './capture-columns' import { CaptureGallery } from './capture-gallery' -export const CollectionDetails = () => { - const { projectId, id } = useParams() +export const Captures = () => { + const { projectId } = useParams() const { selectedView, setSelectedView } = useSelectedView('table') - - // Collection details - const { collection } = useCollectionDetails(id as string) - - // Collection captures + const { filters } = useFilters() const [sort, setSort] = useState() const { pagination, setPage } = usePagination() const { captures, total, isLoading, isFetching, error } = useCaptures({ projectId, - pagination, sort, - filters: [ - { - field: 'collections', - value: id as string, - }, - ], + pagination, + filters, }) return ( - <> - {collection && ( +
+
+ + + + +
+
{ onValueChange={setSelectedView} /> - )} - {selectedView === 'table' && ( - - )} - {selectedView === 'gallery' && ( - - )} + {selectedView === 'table' && ( +
+ )} + {selectedView === 'gallery' && ( + + )} + {captures?.length ? ( { /> ) : null} - + ) } diff --git a/ui/src/pages/job-details/job-details.tsx b/ui/src/pages/job-details/job-details.tsx index 71401b7ef..4ab5f5c37 100644 --- a/ui/src/pages/job-details/job-details.tsx +++ b/ui/src/pages/job-details/job-details.tsx @@ -133,10 +133,6 @@ const JobSummary = ({ job }: { job: Job }) => { ) : job.sourceImages ? ( ) : null} diff --git a/ui/src/pages/jobs/jobs-columns.tsx b/ui/src/pages/jobs/jobs-columns.tsx index 85b676b64..93731aa6b 100644 --- a/ui/src/pages/jobs/jobs-columns.tsx +++ b/ui/src/pages/jobs/jobs-columns.tsx @@ -103,22 +103,9 @@ export const columns: (projectId: string) => TableColumn[] = ( id: 'source-image-collection', sortField: 'source_image_collection', name: translate(STRING.FIELD_LABEL_SOURCE_IMAGES_COLLECTION), - renderCell: (item: Job) => - item.sourceImages ? ( - - - - ) : ( - <> - ), + renderCell: (item: Job) => ( + + ), }, { id: 'created-at', diff --git a/ui/src/pages/project/collections/collection-columns.tsx b/ui/src/pages/project/collections/collection-columns.tsx index 432ecba20..755c7b9b4 100644 --- a/ui/src/pages/project/collections/collection-columns.tsx +++ b/ui/src/pages/project/collections/collection-columns.tsx @@ -30,13 +30,7 @@ export const columns: (projectId: string) => TableColumn[] = ( id: 'name', name: translate(STRING.FIELD_LABEL_NAME), sortField: 'name', - renderCell: (item: Collection) => ( - - - - ), + renderCell: (item: Collection) => , }, { id: 'settings', @@ -48,17 +42,6 @@ export const columns: (projectId: string) => TableColumn[] = ( /> ), }, - { - id: 'captures-with-detections', - name: translate(STRING.FIELD_LABEL_CAPTURES_WITH_DETECTIONS), - sortField: 'source_images_count', - styles: { - textAlign: TextAlign.Right, - }, - renderCell: (item: Collection) => ( - - ), - }, { id: 'status', name: 'Latest job status', @@ -93,6 +76,34 @@ export const columns: (projectId: string) => TableColumn[] = ( ), }, + { + id: 'captures', + name: 'Captures', + sortField: 'source_images_count', + styles: { + textAlign: TextAlign.Right, + }, + renderCell: (item: Collection) => ( + + + + ), + }, + { + id: 'captures-with-detections', + name: translate(STRING.FIELD_LABEL_CAPTURES_WITH_DETECTIONS), + styles: { + textAlign: TextAlign.Right, + }, + renderCell: (item: Collection) => ( + + ), + }, { id: 'occurrences', name: translate(STRING.FIELD_LABEL_OCCURRENCES), diff --git a/ui/src/pages/project/sidebar/useSidebarSections.tsx b/ui/src/pages/project/sidebar/useSidebarSections.tsx index 0e47bf548..df2017b6e 100644 --- a/ui/src/pages/project/sidebar/useSidebarSections.tsx +++ b/ui/src/pages/project/sidebar/useSidebarSections.tsx @@ -25,10 +25,6 @@ const getSidebarSections = ( id: 'collections', title: translate(STRING.NAV_ITEM_COLLECTIONS), path: APP_ROUTES.COLLECTIONS({ projectId: project.id }), - matchPath: APP_ROUTES.COLLECTION_DETAILS({ - projectId: ':projectId', - collectionId: '*', - }), }, { id: 'exports', @@ -40,6 +36,15 @@ const getSidebarSections = ( { title: 'Processing', items: [ + { + id: 'jobs', + title: translate(STRING.NAV_ITEM_JOBS), + path: APP_ROUTES.JOBS({ projectId: project.id }), + matchPath: APP_ROUTES.JOB_DETAILS({ + projectId: ':projectId', + jobId: '*', + }), + }, { id: 'processing-services', title: translate(STRING.NAV_ITEM_PROCESSING_SERVICES), diff --git a/ui/src/utils/constants.ts b/ui/src/utils/constants.ts index 20e52d4a4..87b1ffe98 100644 --- a/ui/src/utils/constants.ts +++ b/ui/src/utils/constants.ts @@ -17,8 +17,8 @@ export const APP_ROUTES = { ALGORITHMS: (params: { projectId: string }) => `/projects/${params.projectId}/algorithms`, - COLLECTION_DETAILS: (params: { projectId: string; collectionId: string }) => - `/projects/${params.projectId}/collections/${params.collectionId}`, + CAPTURES: (params: { projectId: string }) => + `/projects/${params.projectId}/captures`, COLLECTIONS: (params: { projectId: string }) => `/projects/${params.projectId}/collections`, diff --git a/ui/src/utils/getAppRoute.ts b/ui/src/utils/getAppRoute.ts index f6bc990b5..2bb12d398 100644 --- a/ui/src/utils/getAppRoute.ts +++ b/ui/src/utils/getAppRoute.ts @@ -7,6 +7,7 @@ type FilterType = | 'taxon' | 'timestamp' | 'collection' + | 'collections' | 'source_image_collection' | 'source_image_single' diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index e2813ec09..0957546b6 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -181,6 +181,7 @@ export enum STRING { /* NAV_ITEM */ NAV_ITEM_ALGORITHMS, + NAV_ITEM_CAPTURES, NAV_ITEM_COLLECTIONS, NAV_ITEM_DEFAULT_FILTERS, NAV_ITEM_DEPLOYMENTS, @@ -324,7 +325,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_CATEGORY_MAP_ID]: 'Category map ID', [STRING.FIELD_LABEL_CATEGORY_COUNT]: 'Taxa', [STRING.FIELD_LABEL_CAPTURES]: 'Captures', - [STRING.FIELD_LABEL_CAPTURES_WITH_DETECTIONS]: 'Captures w/detections', + [STRING.FIELD_LABEL_CAPTURES_WITH_DETECTIONS]: 'Captures with detections', [STRING.FIELD_LABEL_COMMENT]: 'Comment', [STRING.FIELD_LABEL_CONNECTION_STATUS]: 'Connection status', [STRING.FIELD_LABEL_CREATED_AT]: 'Created at', @@ -471,6 +472,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { /* NAV_ITEM */ [STRING.NAV_ITEM_ALGORITHMS]: 'Algorithms', + [STRING.NAV_ITEM_CAPTURES]: 'Captures', [STRING.NAV_ITEM_COLLECTIONS]: 'Collections', [STRING.NAV_ITEM_DEFAULT_FILTERS]: 'Default filters', [STRING.NAV_ITEM_DEPLOYMENTS]: 'Stations', diff --git a/ui/src/utils/useFilters.ts b/ui/src/utils/useFilters.ts index 6c0de31eb..6060b717d 100644 --- a/ui/src/utils/useFilters.ts +++ b/ui/src/utils/useFilters.ts @@ -27,6 +27,10 @@ export const AVAILABLE_FILTERS: { label: 'Collection', field: 'source_image_collection', // This is for viewing Jobs by collection. @TODO: Can we update this key to "collection" to streamline? }, + { + label: 'Collection', + field: 'collections', // This is for viewing Captures by collection @TODO: Can we update this key to "collection" to streamline? + }, { label: 'Station', field: 'deployment', diff --git a/ui/src/utils/useNavItems.ts b/ui/src/utils/useNavItems.ts index d72c341d6..8e6622ce3 100644 --- a/ui/src/utils/useNavItems.ts +++ b/ui/src/utils/useNavItems.ts @@ -32,20 +32,15 @@ export const useNavItems = () => { path: APP_ROUTES.PROJECT_DETAILS({ projectId: projectId as string }), matchPath: APP_ROUTES.PROJECT_DETAILS({ projectId: ':projectId' }), }, - ...(loggedIn - ? [ - { - id: 'jobs', - title: translate(STRING.NAV_ITEM_JOBS), - icon: IconType.BatchId, - path: APP_ROUTES.JOBS({ projectId: projectId as string }), - matchPath: APP_ROUTES.JOB_DETAILS({ - projectId: ':projectId', - jobId: '*', - }), - }, - ] - : []), + + { + id: 'captures', + title: translate(STRING.NAV_ITEM_CAPTURES), + icon: IconType.Images, + count: status?.numCaptures, + path: APP_ROUTES.CAPTURES({ projectId: projectId as string }), + matchPath: APP_ROUTES.CAPTURES({ projectId: ':projectId' }), + }, { id: 'deployments', title: translate(STRING.NAV_ITEM_DEPLOYMENTS), From 8580823555c02a7e08c8c567e82b3f24aa656094 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 10 Sep 2025 10:38:41 +0200 Subject: [PATCH 03/13] copy: streamline casing for default entities --- ami/main/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ami/main/models.py b/ami/main/models.py index d6fc59f39..0ebaf8729 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -98,14 +98,14 @@ def get_media_url(path: str) -> str: def get_or_create_default_device(project: "Project") -> "Device": """Create a default device for a project.""" - device, _created = Device.objects.get_or_create(name="Default device", project=project) + device, _created = Device.objects.get_or_create(name="Default Device", project=project) logger.info(f"Created default device for project {project}") return device def get_or_create_default_research_site(project: "Project") -> "Site": """Create a default research site for a project.""" - site, _created = Site.objects.get_or_create(name="Default site", project=project) + site, _created = Site.objects.get_or_create(name="Default Site", project=project) logger.info(f"Created default research site for project {project}") return site From efb86b30102dc8e2d66db0dc821dbaebdbcc6dec Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 10 Sep 2025 10:46:36 +0200 Subject: [PATCH 04/13] feat: setup and use image upload dialog --- .../hooks/captures/useCaptures.ts | 4 + .../hooks/captures/useUploadCapture.ts | 6 +- .../hooks/captures/useUploadCaptures.ts | 64 +++++ .../data-services/models/project-details.ts | 5 + .../components/dialog/dialog.module.scss | 3 +- ui/src/pages/captures/captures.tsx | 17 +- .../select-images-section.tsx | 109 ++++++++ .../select-images-section/styles.module.scss | 41 +++ .../upload-images-dialog/styles.module.scss | 21 ++ .../upload-images-dialog.tsx | 256 ++++++++++++++++++ ui/src/pages/project/summary/summary.tsx | 44 ++- ui/src/utils/language.ts | 2 +- 12 files changed, 548 insertions(+), 24 deletions(-) create mode 100644 ui/src/data-services/hooks/captures/useUploadCaptures.ts create mode 100644 ui/src/pages/captures/upload-images-dialog/select-images-section/select-images-section.tsx create mode 100644 ui/src/pages/captures/upload-images-dialog/select-images-section/styles.module.scss create mode 100644 ui/src/pages/captures/upload-images-dialog/styles.module.scss create mode 100644 ui/src/pages/captures/upload-images-dialog/upload-images-dialog.tsx diff --git a/ui/src/data-services/hooks/captures/useCaptures.ts b/ui/src/data-services/hooks/captures/useCaptures.ts index dbc843ed1..5fe5674c9 100644 --- a/ui/src/data-services/hooks/captures/useCaptures.ts +++ b/ui/src/data-services/hooks/captures/useCaptures.ts @@ -3,6 +3,7 @@ import { Capture, ServerCapture } from 'data-services/models/capture' import { FetchParams } from 'data-services/types' import { getFetchUrl } from 'data-services/utils' import { useMemo } from 'react' +import { UserPermission } from 'utils/user/types' import { useAuthorizedQuery } from '../auth/useAuthorizedQuery' const convertServerRecord = (record: ServerCapture) => new Capture(record) @@ -11,6 +12,7 @@ export const useCaptures = ( params: FetchParams ): { captures?: Capture[] + userPermissions?: UserPermission[] total: number isLoading: boolean isFetching: boolean @@ -20,6 +22,7 @@ export const useCaptures = ( const { data, isLoading, isFetching, error } = useAuthorizedQuery<{ results: ServerCapture[] + user_permissions?: UserPermission[] count: number }>({ queryKey: [API_ROUTES.CAPTURES, params], @@ -31,6 +34,7 @@ export const useCaptures = ( return { captures, + userPermissions: data?.user_permissions, total: data?.count ?? 0, isLoading, isFetching, diff --git a/ui/src/data-services/hooks/captures/useUploadCapture.ts b/ui/src/data-services/hooks/captures/useUploadCapture.ts index 520910efc..1335f3530 100644 --- a/ui/src/data-services/hooks/captures/useUploadCapture.ts +++ b/ui/src/data-services/hooks/captures/useUploadCapture.ts @@ -14,7 +14,7 @@ export const useUploadCapture = (onSuccess?: (id: string) => void) => { const { user } = useUser() const queryClient = useQueryClient() - const { mutate, isLoading, error, isSuccess } = useMutation({ + const { mutateAsync, isLoading, error, isSuccess, reset } = useMutation({ mutationFn: (fieldValues: UploadCaptureFieldValues) => { const data = new FormData() if (fieldValues.projectId) { @@ -41,10 +41,12 @@ export const useUploadCapture = (onSuccess?: (id: string) => void) => { ) }, onSuccess: ({ data }) => { + queryClient.invalidateQueries([API_ROUTES.CAPTURES]) queryClient.invalidateQueries([API_ROUTES.DEPLOYMENTS]) + queryClient.invalidateQueries([API_ROUTES.SUMMARY]) onSuccess?.(`${data.source_image.id}`) }, }) - return { uploadCapture: mutate, isLoading, error, isSuccess } + return { uploadCapture: mutateAsync, isLoading, error, isSuccess, reset } } diff --git a/ui/src/data-services/hooks/captures/useUploadCaptures.ts b/ui/src/data-services/hooks/captures/useUploadCaptures.ts new file mode 100644 index 000000000..f31e3f47e --- /dev/null +++ b/ui/src/data-services/hooks/captures/useUploadCaptures.ts @@ -0,0 +1,64 @@ +import { useQueryClient } from '@tanstack/react-query' +import { API_ROUTES, SUCCESS_TIMEOUT } from 'data-services/constants' +import { useState } from 'react' +import { useUploadCapture } from './useUploadCapture' + +type Result = PromiseSettledResult + +const isRejected = (result: Result) => result.status === 'rejected' + +export const useUploadCaptures = (onSuccess?: () => void) => { + const queryClient = useQueryClient() + const [results, setResults] = useState() + const { uploadCapture, isLoading, isSuccess, reset } = useUploadCapture( + () => { + setTimeout(() => { + reset() + }, SUCCESS_TIMEOUT) + } + ) + + const error = results?.some(isRejected) + ? 'Not all images could not be uploaded, please retry.' + : undefined + + return { + isLoading, + isSuccess, + error, + uploadCaptures: async (params: { + projectId: string + deploymentId: string + files: File[] + }) => { + const promises = params.files + .filter((_, index) => { + if (error) { + // Only retry rejected requests + return results?.[index]?.status === 'rejected' + } + + return true + }) + .map((file) => + uploadCapture({ + projectId: params.projectId, + deploymentId: params.deploymentId, + file, + }) + ) + + setResults(undefined) + const updatesResults = await Promise.allSettled(promises) + setResults(updatesResults) + + if (!updatesResults?.some(isRejected)) { + queryClient.invalidateQueries([API_ROUTES.CAPTURES]) + queryClient.invalidateQueries([API_ROUTES.DEPLOYMENTS]) + queryClient.invalidateQueries([API_ROUTES.SUMMARY]) + queryClient.invalidateQueries([API_ROUTES.PROJECTS, params.projectId]) + onSuccess?.() + } + }, + } +} diff --git a/ui/src/data-services/models/project-details.ts b/ui/src/data-services/models/project-details.ts index d46eb9c86..bc7ba030a 100644 --- a/ui/src/data-services/models/project-details.ts +++ b/ui/src/data-services/models/project-details.ts @@ -1,3 +1,4 @@ +import { UserPermission } from 'utils/user/types' import { Project } from './project' export type ServerProject = any // TODO: Update this type @@ -51,4 +52,8 @@ export class ProjectDetails extends Project { get summaryData(): SummaryData[] { return this._project.summary_data } + + get userPermissions(): UserPermission[] { + return this._project.user_permissions + } } diff --git a/ui/src/design-system/components/dialog/dialog.module.scss b/ui/src/design-system/components/dialog/dialog.module.scss index d26557746..e3f47aee7 100644 --- a/ui/src/design-system/components/dialog/dialog.module.scss +++ b/ui/src/design-system/components/dialog/dialog.module.scss @@ -76,8 +76,7 @@ $dialog-padding-medium: 32px; .dialogTitle { all: unset; display: block; - @include paragraph-large(); - font-weight: 600; + @include heading-6(); color: $color-neutral-700; padding-top: 2px; } diff --git a/ui/src/pages/captures/captures.tsx b/ui/src/pages/captures/captures.tsx index 0f152b917..ba8c38307 100644 --- a/ui/src/pages/captures/captures.tsx +++ b/ui/src/pages/captures/captures.tsx @@ -13,9 +13,11 @@ import { useParams } from 'react-router-dom' import { STRING, translate } from 'utils/language' import { useFilters } from 'utils/useFilters' import { usePagination } from 'utils/usePagination' +import { UserPermission } from 'utils/user/types' import { useSelectedView } from 'utils/useSelectedView' import { columns } from './capture-columns' import { CaptureGallery } from './capture-gallery' +import { UploadImagesDialog } from './upload-images-dialog/upload-images-dialog' export const Captures = () => { const { projectId } = useParams() @@ -23,12 +25,14 @@ export const Captures = () => { const { filters } = useFilters() const [sort, setSort] = useState() const { pagination, setPage } = usePagination() - const { captures, total, isLoading, isFetching, error } = useCaptures({ - projectId, - sort, - pagination, - filters, - }) + const { captures, userPermissions, total, isLoading, isFetching, error } = + useCaptures({ + projectId, + sort, + pagination, + filters, + }) + const canCreate = userPermissions?.includes(UserPermission.Create) return (
@@ -63,6 +67,7 @@ export const Captures = () => { value={selectedView} onValueChange={setSelectedView} /> + {canCreate ? : null} {selectedView === 'table' && (
void +}) => { + const canUpload = images.length < CAPTURE_CONFIG.NUM_CAPTURES + + return ( + +
+ {images.map(({ file }) => ( + +
+ +
+
+ + setImages( + images.filter((image) => image.file.name !== file.name) + ) + } + /> +
+
+ ))} + + {canUpload && ( + + ( + + )} + onChange={(files) => { + if (!files) { + return + } + + setImages([ + ...images, + ...Array.from(files).map((file) => ({ + file, + })), + ]) + }} + /> + + )} +
+
+ ) +} + +const Card = ({ children }: { children: ReactNode }) => ( +
+
{children}
+
+) diff --git a/ui/src/pages/captures/upload-images-dialog/select-images-section/styles.module.scss b/ui/src/pages/captures/upload-images-dialog/select-images-section/styles.module.scss new file mode 100644 index 000000000..3d058b41a --- /dev/null +++ b/ui/src/pages/captures/upload-images-dialog/select-images-section/styles.module.scss @@ -0,0 +1,41 @@ +@import 'src/design-system/variables/variables.scss'; +@import 'src/design-system/variables/colors.scss'; +@import 'src/design-system/variables/typography.scss'; + +.collection { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 16px; +} + +.card { + background-color: $color-primary-2-50; + width: 100%; + border: 1px solid $color-neutral-100; + border-radius: 4px; + box-sizing: border-box; + position: relative; + overflow: hidden; +} + +.cardContent { + position: absolute; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + + img { + width: 100%; + height: 100%; + object-fit: contain; + } +} + +.cancelContainer { + position: absolute; + top: 8px; + right: 8px; +} diff --git a/ui/src/pages/captures/upload-images-dialog/styles.module.scss b/ui/src/pages/captures/upload-images-dialog/styles.module.scss new file mode 100644 index 000000000..c5d667f57 --- /dev/null +++ b/ui/src/pages/captures/upload-images-dialog/styles.module.scss @@ -0,0 +1,21 @@ +@import 'src/design-system/variables/variables.scss'; + +.content { + width: 720px; + max-width: 100%; + box-sizing: border-box; + + &.compact { + width: 320px; + } +} + +.section { + margin: 32px 0; +} + +@media only screen and (max-width: $small-screen-breakpoint) { + .section { + margin: 16px 0; + } +} diff --git a/ui/src/pages/captures/upload-images-dialog/upload-images-dialog.tsx b/ui/src/pages/captures/upload-images-dialog/upload-images-dialog.tsx new file mode 100644 index 000000000..8d9c9ffbe --- /dev/null +++ b/ui/src/pages/captures/upload-images-dialog/upload-images-dialog.tsx @@ -0,0 +1,256 @@ +import { + FormActions, + FormError, + FormSection, +} from 'components/form/layout/layout' +import { useUploadCaptures } from 'data-services/hooks/captures/useUploadCaptures' +import { useDeployments } from 'data-services/hooks/deployments/useDeployments' +import { Deployment } from 'data-services/models/deployment' +import * as Dialog from 'design-system/components/dialog/dialog' +import { FormStepper } from 'design-system/components/form-stepper/form-stepper' +import { InputValue } from 'design-system/components/input/input' +import { CheckIcon, Loader2Icon, UploadIcon } from 'lucide-react' +import { Button, Select } from 'nova-ui-kit' +import { useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' +import { STRING, translate } from 'utils/language' +import { SelectImagesSection } from './select-images-section/select-images-section' +import styles from './styles.module.scss' + +const CLOSE_TIMEOUT = 1000 + +enum Section { + Station = 'station', + Images = 'images', + Upload = 'upload', +} + +export const UploadImagesDialog = ({ + buttonSize = 'small', + buttonVariant = 'outline', +}: { + buttonSize?: string + buttonVariant?: string +}) => { + const { projectId } = useParams() + const [isOpen, setIsOpen] = useState(false) + const [currentSection, setCurrentSection] = useState(Section.Station) + const [images, setImages] = useState<{ file: File }[]>([]) + const [deployment, setDeployment] = useState() + const { uploadCaptures, isLoading, isSuccess, error } = useUploadCaptures( + () => + setTimeout(() => { + setIsOpen(false) + }, CLOSE_TIMEOUT) + ) + + useEffect(() => { + setCurrentSection(Section.Station) + setImages([]) + setDeployment(undefined) + }, [isOpen]) + + return ( + + + + + + + {error ? : null} +
+
+ +
+ {currentSection === Section.Station ? ( + + ) : null} + {currentSection === Section.Images ? ( + + ) : null} + {currentSection === Section.Upload ? ( + { + if (deployment && images.length) { + uploadCaptures({ + projectId: projectId as string, + deploymentId: deployment?.id, + files: images.map(({ file }) => file), + }) + } + }} + setCurrentSection={setCurrentSection} + /> + ) : null} +
+
+
+ ) +} + +const SectionStation = ({ + deployment, + setCurrentSection, + setDeployment, +}: { + deployment?: Deployment + setCurrentSection: (section: Section) => void + setDeployment: (deployment?: Deployment) => void +}) => { + const { projectId } = useParams() + const { deployments = [], isLoading } = useDeployments({ + projectId: projectId as string, + }) + + return ( +
+ + + setDeployment( + deployments.find((deployment) => deployment.id === value) + ) + } + > + + + + + {deployments.map((d) => ( + + {d.name} + + ))} + + + + + + +
+ ) +} + +const SectionImages = ({ + images, + setCurrentSection, + setImages, +}: { + images: { file: File }[] + setCurrentSection: (section: Section) => void + setImages: (images: { file: File }[]) => void +}) => ( +
+ +
+ + + + +
+) + +const SectionUpload = ({ + deployment, + images, + isLoading, + isSuccess, + onSubmit, + setCurrentSection, +}: { + deployment?: Deployment + images: { file: File }[] + isLoading: boolean + isSuccess: boolean + onSubmit: () => void + setCurrentSection: (section: Section) => void +}) => ( +
+ +
+ + +
+
+ + + + +
+) diff --git a/ui/src/pages/project/summary/summary.tsx b/ui/src/pages/project/summary/summary.tsx index e5d7f6adc..3e4562c43 100644 --- a/ui/src/pages/project/summary/summary.tsx +++ b/ui/src/pages/project/summary/summary.tsx @@ -1,30 +1,48 @@ +import { useStatus } from 'data-services/hooks/useStatus' import { ProjectDetails } from 'data-services/models/project-details' import { Box } from 'design-system/components/box/box' import { PlotGrid } from 'design-system/components/plot-grid/plot-grid' import { Plot } from 'design-system/components/plot/lazy-plot' +import { UploadImagesDialog } from 'pages/captures/upload-images-dialog/upload-images-dialog' import { useOutletContext } from 'react-router-dom' +import { UserPermission } from 'utils/user/types' import { DeploymentsMap } from './deployments-map' export const Summary = () => { const { project } = useOutletContext<{ project: ProjectDetails }>() + const { status } = useStatus(project.id) + const canUpload = project.userPermissions.includes(UserPermission.Update) return (
- - - {project.summaryData.map((summary, index) => ( - - - - ))} - + {status && status.numCaptures === 0 && canUpload ? ( +
+

Welcome!

+

+ To fill your project with data, upload a few sample images or + configure a data source. +

+ +
+ ) : ( + <> + + + {project.summaryData.map((summary, index) => ( + + + + ))} + + + )}
) } diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index 0957546b6..601d6c9a9 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -429,7 +429,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.MESSAGE_CAPTURE_FILENAME]: 'Image filename must contain a timestamp with year, month, day, hours, minutes and seconds (e.g. 20210101120000-snapshot.jpg).', [STRING.MESSAGE_CAPTURE_LIMIT]: - 'A maximum of {{numCaptures}} images can be uploaded through the web browser. Configure a data source to upload data in bulk.', + 'A maximum of {{numCaptures}} images for each station can be uploaded through the web browser. Configure a data source to upload data in bulk.', [STRING.MESSAGE_CAPTURE_SYNC_HIDDEN]: 'Station must be created before syncing images.', [STRING.MESSAGE_CAPTURE_TOO_MANY]: From 9998920806dd55e1c1504d93a1bdeeb8608caf1a Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 10 Sep 2025 10:48:08 +0200 Subject: [PATCH 05/13] feat: show get started information in project gallery --- .../project-details/new-project-dialog.tsx | 10 +++++-- ui/src/pages/projects/projects.tsx | 27 ++++++++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/ui/src/pages/project-details/new-project-dialog.tsx b/ui/src/pages/project-details/new-project-dialog.tsx index d992df6ca..54c302fe1 100644 --- a/ui/src/pages/project-details/new-project-dialog.tsx +++ b/ui/src/pages/project-details/new-project-dialog.tsx @@ -14,7 +14,13 @@ const newProject = new Project({ id: 'new-project', }) -export const NewProjectDialog = () => { +export const NewProjectDialog = ({ + buttonSize = 'small', + buttonVariant = 'outline', +}: { + buttonSize?: string + buttonVariant?: string +}) => { const [isOpen, setIsOpen] = useState(false) const { createProject, isLoading, isSuccess, error } = useCreateProject(() => setTimeout(() => { @@ -29,7 +35,7 @@ export const NewProjectDialog = () => { return ( - diff --git a/ui/src/pages/projects/projects.tsx b/ui/src/pages/projects/projects.tsx index d8d30588e..f25c7cfbe 100644 --- a/ui/src/pages/projects/projects.tsx +++ b/ui/src/pages/projects/projects.tsx @@ -3,6 +3,7 @@ import { PageFooter } from 'design-system/components/page-footer/page-footer' import { PageHeader } from 'design-system/components/page-header/page-header' import { PaginationBar } from 'design-system/components/pagination-bar/pagination-bar' import * as Tabs from 'design-system/components/tabs/tabs' +import { Button } from 'nova-ui-kit' import { NewProjectDialog } from 'pages/project-details/new-project-dialog' import { STRING, translate } from 'utils/language' import { usePagination } from 'utils/usePagination' @@ -65,7 +66,31 @@ export const Projects = () => { ) : null} {canCreate ? : null} - + {projects && projects.length === 0 && canCreate ? ( +
+

Get started

+

+ To start use Antenna, register your first project or view public + ones. +

+
+ +

or

+ +
+
+ ) : ( + + )} {projects?.length ? ( Date: Wed, 10 Sep 2025 10:49:25 +0200 Subject: [PATCH 06/13] copy: streamline select placeholders --- ui/src/design-system/components/select/select.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/design-system/components/select/select.tsx b/ui/src/design-system/components/select/select.tsx index a7bf3e4b8..e3765b3a1 100644 --- a/ui/src/design-system/components/select/select.tsx +++ b/ui/src/design-system/components/select/select.tsx @@ -30,7 +30,7 @@ export const Select = ({ label, loading, options = [], - placeholder = 'Pick a value', + placeholder = 'Select a value', showClear = true, theme = SelectTheme.Default, value, From 6c8652d881ae3e154a68b27a13c1b6e3d17c6bbb Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 10 Sep 2025 11:00:10 +0200 Subject: [PATCH 07/13] feat: navigate to project after creation --- .../hooks/projects/useCreateProject.ts | 2 +- .../project-details/new-project-dialog.tsx | 28 +++++++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/ui/src/data-services/hooks/projects/useCreateProject.ts b/ui/src/data-services/hooks/projects/useCreateProject.ts index f83c32c64..3d9b62eb5 100644 --- a/ui/src/data-services/hooks/projects/useCreateProject.ts +++ b/ui/src/data-services/hooks/projects/useCreateProject.ts @@ -11,7 +11,7 @@ export const useCreateProject = (onSuccess?: () => void) => { const { mutateAsync, isLoading, isSuccess, reset, error } = useMutation({ mutationFn: (fieldValues: any) => - axios.post( + axios.post<{ id: number }>( `${API_URL}/${API_ROUTES.PROJECTS}/`, convertToServerFormData(fieldValues), { diff --git a/ui/src/pages/project-details/new-project-dialog.tsx b/ui/src/pages/project-details/new-project-dialog.tsx index 54c302fe1..64b30cab8 100644 --- a/ui/src/pages/project-details/new-project-dialog.tsx +++ b/ui/src/pages/project-details/new-project-dialog.tsx @@ -4,6 +4,9 @@ import * as Dialog from 'design-system/components/dialog/dialog' import { PlusIcon } from 'lucide-react' import { Button } from 'nova-ui-kit' import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { APP_ROUTES } from 'utils/constants' +import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' import { ProjectDetailsForm } from './project-details-form' import styles from './styles.module.scss' @@ -21,12 +24,9 @@ export const NewProjectDialog = ({ buttonSize?: string buttonVariant?: string }) => { + const navigate = useNavigate() + const { createProject, isLoading, isSuccess, error } = useCreateProject() const [isOpen, setIsOpen] = useState(false) - const { createProject, isLoading, isSuccess, error } = useCreateProject(() => - setTimeout(() => { - setIsOpen(false) - }, CLOSE_TIMEOUT) - ) const label = translate(STRING.ENTITY_CREATE, { type: translate(STRING.ENTITY_TYPE_PROJECT), @@ -48,7 +48,23 @@ export const NewProjectDialog = ({ error={error} isLoading={isLoading} isSuccess={isSuccess} - onSubmit={(data) => createProject(data)} + onSubmit={async (data) => { + const response = await createProject(data) + + setTimeout(() => { + setIsOpen(false) + }, CLOSE_TIMEOUT) + + if (response.data.id) { + navigate( + getAppRoute({ + to: APP_ROUTES.PROJECT_DETAILS({ + projectId: `${response.data.id}`, + }), + }) + ) + } + }} />
From 1b95e16c9a6770acc9b7a95a785fce8ea116137e Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 10 Sep 2025 11:26:22 +0200 Subject: [PATCH 08/13] fix: tweak capture links --- .../pages/deployments/deployment-columns.tsx | 28 +++++++++++++------ .../collections/collection-columns.tsx | 2 +- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/ui/src/pages/deployments/deployment-columns.tsx b/ui/src/pages/deployments/deployment-columns.tsx index 914e8919f..bf7db2b4f 100644 --- a/ui/src/pages/deployments/deployment-columns.tsx +++ b/ui/src/pages/deployments/deployment-columns.tsx @@ -89,32 +89,42 @@ export const columns: (projectId: string) => TableColumn[] = ( ), }, { - id: 'sessions', - name: translate(STRING.FIELD_LABEL_SESSIONS), - sortField: 'numEvents', + id: 'captures', + name: translate(STRING.FIELD_LABEL_CAPTURES), + sortField: 'numImages', styles: { textAlign: TextAlign.Right, }, renderCell: (item: Deployment) => ( - + ), }, { - id: 'captures', - name: translate(STRING.FIELD_LABEL_CAPTURES), - sortField: 'numImages', + id: 'sessions', + name: translate(STRING.FIELD_LABEL_SESSIONS), + sortField: 'numEvents', styles: { textAlign: TextAlign.Right, }, - renderCell: (item: Deployment) => , + renderCell: (item: Deployment) => ( + + + + ), }, + { id: 'occurrences', name: translate(STRING.FIELD_LABEL_OCCURRENCES), diff --git a/ui/src/pages/project/collections/collection-columns.tsx b/ui/src/pages/project/collections/collection-columns.tsx index 755c7b9b4..a3ee5018b 100644 --- a/ui/src/pages/project/collections/collection-columns.tsx +++ b/ui/src/pages/project/collections/collection-columns.tsx @@ -87,7 +87,7 @@ export const columns: (projectId: string) => TableColumn[] = ( From 2b59d8e82530bbf0a15a68dde8637b50b48b0cb6 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 10 Sep 2025 11:44:58 +0200 Subject: [PATCH 09/13] fix: tweak default column settings for collections --- ui/src/pages/project/collections/collections.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/src/pages/project/collections/collections.tsx b/ui/src/pages/project/collections/collections.tsx index a0c5c4ff4..e53e14db3 100644 --- a/ui/src/pages/project/collections/collections.tsx +++ b/ui/src/pages/project/collections/collections.tsx @@ -26,6 +26,7 @@ export const Collections = () => { id: true, name: true, settings: true, + captures: true, 'captures-with-detections': true, status: true, } From 2c1e35f778bf331770b4e3c2c8a903e049a75829 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Fri, 12 Sep 2025 16:10:01 +0200 Subject: [PATCH 10/13] copy: tweak wording --- ui/src/pages/projects/projects.tsx | 2 +- ui/src/utils/language.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/pages/projects/projects.tsx b/ui/src/pages/projects/projects.tsx index f25c7cfbe..7f8520f66 100644 --- a/ui/src/pages/projects/projects.tsx +++ b/ui/src/pages/projects/projects.tsx @@ -70,7 +70,7 @@ export const Projects = () => {

Get started

- To start use Antenna, register your first project or view public + To start use Antenna, create your first project or view the public ones.

diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index 601d6c9a9..dc6304d31 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -409,7 +409,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_VERSION_NAME]: 'Version Name', /* ENTITY */ - [STRING.ENTITY_CREATE]: 'Register new {{type}}', + [STRING.ENTITY_CREATE]: 'Create new {{type}}', [STRING.ENTITY_DELETE]: 'Delete {{type}}', [STRING.ENTITY_DETAILS]: '{{type}} details', [STRING.ENTITY_EDIT]: 'Edit {{type}}', From a97c3be73f1fbf87c370ef65722b51f9d1a058d6 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Mon, 15 Sep 2025 18:34:37 +0200 Subject: [PATCH 11/13] fix: bring back jobs as main menu item --- .../pages/project/sidebar/useSidebarSections.tsx | 9 --------- ui/src/utils/useNavItems.ts | 15 ++++++++++++++- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/ui/src/pages/project/sidebar/useSidebarSections.tsx b/ui/src/pages/project/sidebar/useSidebarSections.tsx index df2017b6e..99d739984 100644 --- a/ui/src/pages/project/sidebar/useSidebarSections.tsx +++ b/ui/src/pages/project/sidebar/useSidebarSections.tsx @@ -36,15 +36,6 @@ const getSidebarSections = ( { title: 'Processing', items: [ - { - id: 'jobs', - title: translate(STRING.NAV_ITEM_JOBS), - path: APP_ROUTES.JOBS({ projectId: project.id }), - matchPath: APP_ROUTES.JOB_DETAILS({ - projectId: ':projectId', - jobId: '*', - }), - }, { id: 'processing-services', title: translate(STRING.NAV_ITEM_PROCESSING_SERVICES), diff --git a/ui/src/utils/useNavItems.ts b/ui/src/utils/useNavItems.ts index 8e6622ce3..a5e1c7c0c 100644 --- a/ui/src/utils/useNavItems.ts +++ b/ui/src/utils/useNavItems.ts @@ -32,7 +32,20 @@ export const useNavItems = () => { path: APP_ROUTES.PROJECT_DETAILS({ projectId: projectId as string }), matchPath: APP_ROUTES.PROJECT_DETAILS({ projectId: ':projectId' }), }, - + ...(loggedIn + ? [ + { + id: 'jobs', + title: translate(STRING.NAV_ITEM_JOBS), + icon: IconType.BatchId, + path: APP_ROUTES.JOBS({ projectId: projectId as string }), + matchPath: APP_ROUTES.JOB_DETAILS({ + projectId: ':projectId', + jobId: '*', + }), + }, + ] + : []), { id: 'captures', title: translate(STRING.NAV_ITEM_CAPTURES), From 8feff0008535b41e5cdbc3dcbc4d290484694922 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Mon, 15 Sep 2025 19:19:27 -0700 Subject: [PATCH 12/13] fix: link to session detail --- ui/src/pages/captures/capture-columns.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/captures/capture-columns.tsx b/ui/src/pages/captures/capture-columns.tsx index 944522378..89e32b7f4 100644 --- a/ui/src/pages/captures/capture-columns.tsx +++ b/ui/src/pages/captures/capture-columns.tsx @@ -73,7 +73,10 @@ export const columns: (projectId: string) => TableColumn[] = ( renderCell: (item: Capture) => item.sessionId ? ( From 74137ef4440ba4c2718c4b58fe1107ff35ca66f0 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Mon, 15 Sep 2025 19:21:19 -0700 Subject: [PATCH 13/13] fix: missing occurrence filter for single source images --- ami/main/api/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ami/main/api/views.py b/ami/main/api/views.py index a92309bb5..7d7ee0705 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -1080,6 +1080,7 @@ class OccurrenceViewSet(DefaultViewSet, ProjectMixin): "event", "deployment", "determination__rank", + "detections__source_image", ] ordering_fields = [ "created_at",