From 7f8349469a73a7b8215acdd1f8a142d6a65cbe67 Mon Sep 17 00:00:00 2001 From: dogwithakeyboard <128322708+dogwithakeyboard@users.noreply.github.com> Date: Fri, 29 Nov 2024 06:02:20 +0000 Subject: [PATCH] Scene Marker grid view (#5443) * add bulk delete mutation --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/schema.graphql | 1 + internal/api/resolver_mutation_scene.go | 57 +++-- ui/v2.5/graphql/data/scene-marker.graphql | 17 +- .../graphql/mutations/scene-marker.graphql | 4 + .../Scenes/DeleteSceneMarkersDialog.tsx | 83 +++++++ .../src/components/Scenes/PreviewScrubber.tsx | 2 +- .../src/components/Scenes/SceneMarkerCard.tsx | 214 ++++++++++++++++++ .../Scenes/SceneMarkerCardsGrid.tsx | 38 ++++ .../src/components/Scenes/SceneMarkerList.tsx | 33 ++- ui/v2.5/src/components/Scenes/styles.scss | 4 +- .../Settings/Tasks/GenerateOptions.tsx | 2 - ui/v2.5/src/components/Shared/TagLink.tsx | 9 +- ui/v2.5/src/core/StashService.ts | 18 ++ ui/v2.5/src/locales/en-GB.json | 2 +- .../src/models/list-filter/scene-markers.ts | 2 +- ui/v2.5/src/utils/navigation.ts | 27 +++ 16 files changed, 482 insertions(+), 31 deletions(-) create mode 100644 ui/v2.5/src/components/Scenes/DeleteSceneMarkersDialog.tsx create mode 100644 ui/v2.5/src/components/Scenes/SceneMarkerCard.tsx create mode 100644 ui/v2.5/src/components/Scenes/SceneMarkerCardsGrid.tsx diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 251c2af83..31218577d 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -300,6 +300,7 @@ type Mutation { sceneMarkerCreate(input: SceneMarkerCreateInput!): SceneMarker sceneMarkerUpdate(input: SceneMarkerUpdateInput!): SceneMarker sceneMarkerDestroy(id: ID!): Boolean! + sceneMarkersDestroy(ids: [ID!]!): Boolean! sceneAssignFile(input: AssignSceneFileInput!): Boolean! diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index 101cc8ba5..644732be9 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -814,11 +814,16 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar } func (r *mutationResolver) SceneMarkerDestroy(ctx context.Context, id string) (bool, error) { - markerID, err := strconv.Atoi(id) + return r.SceneMarkersDestroy(ctx, []string{id}) +} + +func (r *mutationResolver) SceneMarkersDestroy(ctx context.Context, markerIDs []string) (bool, error) { + ids, err := stringslice.StringSliceToIntSlice(markerIDs) if err != nil { - return false, fmt.Errorf("converting id: %w", err) + return false, fmt.Errorf("converting ids: %w", err) } + var markers []*models.SceneMarker fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm() fileDeleter := &scene.FileDeleter{ @@ -831,35 +836,45 @@ func (r *mutationResolver) SceneMarkerDestroy(ctx context.Context, id string) (b qb := r.repository.SceneMarker sqb := r.repository.Scene - marker, err := qb.Find(ctx, markerID) + for _, markerID := range ids { + marker, err := qb.Find(ctx, markerID) - if err != nil { - return err + if err != nil { + return err + } + + if marker == nil { + return fmt.Errorf("scene marker with id %d not found", markerID) + } + + s, err := sqb.Find(ctx, marker.SceneID) + + if err != nil { + return err + } + + if s == nil { + return fmt.Errorf("scene with id %d not found", marker.SceneID) + } + + markers = append(markers, marker) + + if err := scene.DestroyMarker(ctx, s, marker, qb, fileDeleter); err != nil { + return err + } } - if marker == nil { - return fmt.Errorf("scene marker with id %d not found", markerID) - } - - s, err := sqb.Find(ctx, marker.SceneID) - if err != nil { - return err - } - - if s == nil { - return fmt.Errorf("scene with id %d not found", marker.SceneID) - } - - return scene.DestroyMarker(ctx, s, marker, qb, fileDeleter) + return nil }); err != nil { fileDeleter.Rollback() return false, err } - // perform the post-commit actions fileDeleter.Commit() - r.hookExecutor.ExecutePostHooks(ctx, markerID, hook.SceneMarkerDestroyPost, id, nil) + for _, marker := range markers { + r.hookExecutor.ExecutePostHooks(ctx, marker.ID, hook.SceneMarkerDestroyPost, markerIDs, nil) + } return true, nil } diff --git a/ui/v2.5/graphql/data/scene-marker.graphql b/ui/v2.5/graphql/data/scene-marker.graphql index e2ebfc4df..a5dbc8a6c 100644 --- a/ui/v2.5/graphql/data/scene-marker.graphql +++ b/ui/v2.5/graphql/data/scene-marker.graphql @@ -8,7 +8,7 @@ fragment SceneMarkerData on SceneMarker { screenshot scene { - id + ...SceneMarkerSceneData } primary_tag { @@ -21,3 +21,18 @@ fragment SceneMarkerData on SceneMarker { name } } + +fragment SceneMarkerSceneData on Scene { + id + title + files { + width + height + path + } + performers { + id + name + image_path + } +} diff --git a/ui/v2.5/graphql/mutations/scene-marker.graphql b/ui/v2.5/graphql/mutations/scene-marker.graphql index 3b1de35c7..766e318fc 100644 --- a/ui/v2.5/graphql/mutations/scene-marker.graphql +++ b/ui/v2.5/graphql/mutations/scene-marker.graphql @@ -47,3 +47,7 @@ mutation SceneMarkerUpdate( mutation SceneMarkerDestroy($id: ID!) { sceneMarkerDestroy(id: $id) } + +mutation SceneMarkersDestroy($ids: [ID!]!) { + sceneMarkersDestroy(ids: $ids) +} diff --git a/ui/v2.5/src/components/Scenes/DeleteSceneMarkersDialog.tsx b/ui/v2.5/src/components/Scenes/DeleteSceneMarkersDialog.tsx new file mode 100644 index 000000000..01d072226 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/DeleteSceneMarkersDialog.tsx @@ -0,0 +1,83 @@ +import React, { useState } from "react"; +import { useSceneMarkersDestroy } from "src/core/StashService"; +import * as GQL from "src/core/generated-graphql"; +import { ModalComponent } from "src/components/Shared/Modal"; +import { useToast } from "src/hooks/Toast"; +import { useIntl } from "react-intl"; +import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; + +interface IDeleteSceneMarkersDialogProps { + selected: GQL.SceneMarkerDataFragment[]; + onClose: (confirmed: boolean) => void; +} + +export const DeleteSceneMarkersDialog: React.FC< + IDeleteSceneMarkersDialogProps +> = (props: IDeleteSceneMarkersDialogProps) => { + const intl = useIntl(); + const singularEntity = intl.formatMessage({ id: "marker" }); + const pluralEntity = intl.formatMessage({ id: "markers" }); + + const header = intl.formatMessage( + { id: "dialogs.delete_object_title" }, + { count: props.selected.length, singularEntity, pluralEntity } + ); + const toastMessage = intl.formatMessage( + { id: "toast.delete_past_tense" }, + { count: props.selected.length, singularEntity, pluralEntity } + ); + const message = intl.formatMessage( + { id: "dialogs.delete_object_desc" }, + { count: props.selected.length, singularEntity, pluralEntity } + ); + + const Toast = useToast(); + const [deleteSceneMarkers] = useSceneMarkersDestroy( + getSceneMarkersDeleteInput() + ); + + // Network state + const [isDeleting, setIsDeleting] = useState(false); + + function getSceneMarkersDeleteInput(): GQL.SceneMarkersDestroyMutationVariables { + return { + ids: props.selected.map((marker) => marker.id), + }; + } + + async function onDelete() { + setIsDeleting(true); + try { + await deleteSceneMarkers(); + Toast.success(toastMessage); + props.onClose(true); + } catch (e) { + Toast.error(e); + props.onClose(false); + } + setIsDeleting(false); + } + + return ( + props.onClose(false), + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "secondary", + }} + isRunning={isDeleting} + > +

{message}

+
+ ); +}; + +export default DeleteSceneMarkersDialog; diff --git a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx index eb8f2c104..143daca4f 100644 --- a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx +++ b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx @@ -94,7 +94,7 @@ export const PreviewScrubber: React.FC = ({ onClick(s.start); } - if (spriteInfo === null) return null; + if (spriteInfo === null || !vttPath) return null; return (
diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerCard.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerCard.tsx new file mode 100644 index 000000000..b18e1fa54 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneMarkerCard.tsx @@ -0,0 +1,214 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { Button, ButtonGroup } from "react-bootstrap"; +import * as GQL from "src/core/generated-graphql"; +import { Icon } from "../Shared/Icon"; +import { TagLink } from "../Shared/TagLink"; +import { HoverPopover } from "../Shared/HoverPopover"; +import NavUtils from "src/utils/navigation"; +import TextUtils from "src/utils/text"; +import { ConfigurationContext } from "src/hooks/Config"; +import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard"; +import { faTag } from "@fortawesome/free-solid-svg-icons"; +import ScreenUtils from "src/utils/screen"; +import { markerTitle } from "src/core/markers"; +import { Link } from "react-router-dom"; +import { objectTitle } from "src/core/files"; +import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; +import { ScenePreview } from "./SceneCard"; +import { TruncatedText } from "../Shared/TruncatedText"; + +interface ISceneMarkerCardProps { + marker: GQL.SceneMarkerDataFragment; + containerWidth?: number; + previewHeight?: number; + index?: number; + compact?: boolean; + selecting?: boolean; + selected?: boolean | undefined; + zoomIndex?: number; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; +} + +const SceneMarkerCardPopovers = (props: ISceneMarkerCardProps) => { + function maybeRenderPerformerPopoverButton() { + if (props.marker.scene.performers.length <= 0) return; + + return ( + + ); + } + + function renderTagPopoverButton() { + const popoverContent = [ + , + ]; + + props.marker.tags.map((tag) => + popoverContent.push( + + ) + ); + + return ( + + + + ); + } + + function renderPopoverButtonGroup() { + if (!props.compact) { + return ( + <> +
+ + {maybeRenderPerformerPopoverButton()} + {renderTagPopoverButton()} + + + ); + } + } + + return <>{renderPopoverButtonGroup()}; +}; + +const SceneMarkerCardDetails = (props: ISceneMarkerCardProps) => { + return ( +
+ + {TextUtils.formatTimestampRange( + props.marker.seconds, + props.marker.end_seconds ?? undefined + )} + + + {objectTitle(props.marker.scene)} + + } + /> +
+ ); +}; + +const SceneMarkerCardImage = (props: ISceneMarkerCardProps) => { + const { configuration } = React.useContext(ConfigurationContext); + + const file = useMemo( + () => + props.marker.scene.files.length > 0 + ? props.marker.scene.files[0] + : undefined, + [props.marker.scene] + ); + + function isPortrait() { + const width = file?.width ? file.width : 0; + const height = file?.height ? file.height : 0; + return height > width; + } + + function maybeRenderSceneSpecsOverlay() { + return ( +
+ {props.marker.end_seconds && ( + + {TextUtils.secondsToTimestamp( + props.marker.end_seconds - props.marker.seconds + )} + + )} +
+ ); + } + + return ( + <> + + {maybeRenderSceneSpecsOverlay()} + + ); +}; + +export const SceneMarkerCard = (props: ISceneMarkerCardProps) => { + const [cardWidth, setCardWidth] = useState(); + + function zoomIndex() { + if (!props.compact && props.zoomIndex !== undefined) { + return `zoom-${props.zoomIndex}`; + } + + return ""; + } + + useEffect(() => { + if ( + !props.containerWidth || + props.zoomIndex === undefined || + ScreenUtils.isMobile() + ) + return; + + let zoomValue = props.zoomIndex; + let preferredCardWidth: number; + switch (zoomValue) { + case 0: + preferredCardWidth = 240; + break; + case 1: + preferredCardWidth = 340; // this value is intentionally higher than 320 + break; + case 2: + preferredCardWidth = 480; + break; + case 3: + preferredCardWidth = 640; + } + let fittedCardWidth = calculateCardWidth( + props.containerWidth, + preferredCardWidth! + ); + setCardWidth(fittedCardWidth); + }, [props, props.containerWidth, props.zoomIndex]); + + return ( + } + details={} + popovers={} + selected={props.selected} + selecting={props.selecting} + onSelectedChanged={props.onSelectedChanged} + /> + ); +}; diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerCardsGrid.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerCardsGrid.tsx new file mode 100644 index 000000000..6532f535a --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneMarkerCardsGrid.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import * as GQL from "src/core/generated-graphql"; +import { SceneMarkerCard } from "./SceneMarkerCard"; +import { useContainerDimensions } from "../Shared/GridCard/GridCard"; + +interface ISceneMarkerCardsGrid { + markers: GQL.SceneMarkerDataFragment[]; + selectedIds: Set; + zoomIndex: number; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +} + +export const SceneMarkerCardsGrid: React.FC = ({ + markers, + selectedIds, + zoomIndex, + onSelectChange, +}) => { + const [componentRef, { width }] = useContainerDimensions(); + return ( +
+ {markers.map((marker, index) => ( + 0} + selected={selectedIds.has(marker.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(marker.id, selected, shiftKey) + } + /> + ))} +
+ ); +}; diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index 2bf7ae8db..33ae79558 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -14,6 +14,8 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { MarkerWallPanel } from "../Wall/WallPanel"; import { View } from "../List/views"; +import { SceneMarkerCardsGrid } from "./SceneMarkerCardsGrid"; +import { DeleteSceneMarkersDialog } from "./DeleteSceneMarkersDialog"; function getItems(result: GQL.FindSceneMarkersQueryResult) { return result?.data?.findSceneMarkers?.scene_markers ?? []; @@ -27,6 +29,7 @@ interface ISceneMarkerList { filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; alterQuery?: boolean; + defaultSort?: string; } export const SceneMarkerList: React.FC = ({ @@ -84,7 +87,9 @@ export const SceneMarkerList: React.FC = ({ function renderContent( result: GQL.FindSceneMarkersQueryResult, - filter: ListFilterModel + filter: ListFilterModel, + selectedIds: Set, + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void ) { if (!result.data?.findSceneMarkers) return; @@ -93,6 +98,29 @@ export const SceneMarkerList: React.FC = ({ ); } + + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + } + + function renderDeleteDialog( + selectedSceneMarkers: GQL.SceneMarkerDataFragment[], + onClose: (confirmed: boolean) => void + ) { + return ( + + ); } return ( @@ -104,12 +132,15 @@ export const SceneMarkerList: React.FC = ({ alterQuery={alterQuery} filterHook={filterHook} view={view} + selectable > ); diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index ca1d051cd..bb50236ec 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -215,6 +215,7 @@ textarea.scene-description { } .scene-card, +.scene-marker-card, .gallery-card { .scene-specs-overlay { transition: opacity 0.5s; @@ -272,7 +273,8 @@ textarea.scene-description { } } -.scene-card.card { +.scene-card.card, +.scene-marker-card.card { overflow: hidden; padding: 0; diff --git a/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx b/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx index c0127b5db..00d129be7 100644 --- a/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx @@ -110,9 +110,7 @@ export const GenerateOptions: React.FC = ({ } /> = ({ interface IPerformerLinkProps { performer: INamedObject & { disambiguation?: string | null }; - linkType?: "scene" | "gallery" | "image"; + linkType?: "scene" | "gallery" | "image" | "scene_marker"; className?: string; } @@ -55,6 +55,8 @@ export const PerformerLink: React.FC = ({ return NavUtils.makePerformerGalleriesUrl(performer); case "image": return NavUtils.makePerformerImagesUrl(performer); + case "scene_marker": + return NavUtils.makePerformerSceneMarkersUrl(performer); case "scene": default: return NavUtils.makePerformerScenesUrl(performer); @@ -209,7 +211,8 @@ interface ITagLinkProps { | "details" | "performer" | "group" - | "studio"; + | "studio" + | "scene_marker"; className?: string; hoverPlacement?: Placement; showHierarchyIcon?: boolean; @@ -238,6 +241,8 @@ export const TagLink: React.FC = ({ return NavUtils.makeTagImagesUrl(tag); case "group": return NavUtils.makeTagGroupsUrl(tag); + case "scene_marker": + return NavUtils.makeTagSceneMarkersUrl(tag); case "details": return NavUtils.makeTagUrl(tag.id ?? ""); } diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 89500419f..1d9e344eb 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -1499,6 +1499,24 @@ export const useSceneMarkerDestroy = () => }, }); +export const useSceneMarkersDestroy = ( + input: GQL.SceneMarkersDestroyMutationVariables +) => + GQL.useSceneMarkersDestroyMutation({ + variables: input, + update(cache, result) { + if (!result.data?.sceneMarkersDestroy) return; + + for (const id of input.ids) { + const obj = { __typename: "SceneMarker", id }; + cache.evict({ id: cache.identify(obj) }); + } + + evictTypeFields(cache, sceneMarkerMutationImpactedTypeFields); + evictQueries(cache, sceneMarkerMutationImpactedQueries); + }, + }); + const galleryMutationImpactedTypeFields = { Scene: ["galleries"], Performer: ["gallery_count", "performer_count"], diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 143632af0..ac477d188 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -939,7 +939,7 @@ "marker_image_previews": "Marker Animated Image Previews", "marker_image_previews_tooltip": "Also generate animated (webp) previews, only required when Scene/Marker Wall Preview Type is set to Animated Image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.", "marker_screenshots": "Marker Screenshots", - "marker_screenshots_tooltip": "Marker static JPG images, only required if Preview Type is set to Static Image.", + "marker_screenshots_tooltip": "Marker static JPG images", "markers": "Marker Previews", "markers_tooltip": "20 second videos which begin at the given timecode.", "override_preview_generation_options": "Override Preview Generation Options", diff --git a/ui/v2.5/src/models/list-filter/scene-markers.ts b/ui/v2.5/src/models/list-filter/scene-markers.ts index a70cd1629..fa7d4e71a 100644 --- a/ui/v2.5/src/models/list-filter/scene-markers.ts +++ b/ui/v2.5/src/models/list-filter/scene-markers.ts @@ -18,7 +18,7 @@ const sortByOptions = [ "random", "scenes_updated_at", ].map(ListFilterOptions.createSortBy); -const displayModeOptions = [DisplayMode.Wall]; +const displayModeOptions = [DisplayMode.Grid, DisplayMode.Wall]; const criterionOptions = [ TagsCriterionOption, MarkersScenesCriterionOption, diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index 4b4b2bf69..f6712fb58 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -30,6 +30,8 @@ import { PhashCriterion } from "src/models/list-filter/criteria/phash"; import { ILabeledId } from "src/models/list-filter/types"; import { IntlShape } from "react-intl"; import { galleryTitle } from "src/core/galleries"; +import { MarkersScenesCriterion } from "src/models/list-filter/criteria/scenes"; +import { objectTitle } from "src/core/files"; function addExtraCriteria( dest: Criterion[], @@ -129,6 +131,20 @@ const makePerformerGroupsUrl = ( return `/groups?${filter.makeQueryParameters()}`; }; +const makePerformerSceneMarkersUrl = ( + performer: Partial +) => { + if (!performer.id) return "#"; + const filter = new ListFilterModel(GQL.FilterMode.SceneMarkers, undefined); + const criterion = new PerformersCriterion(); + criterion.value.items = [ + { id: performer.id, label: performer.name || `Performer ${performer.id}` }, + ]; + + filter.criteria.push(criterion); + return `/scenes/markers?${filter.makeQueryParameters()}`; +}; + const makePerformersCountryUrl = ( performer: Partial ) => { @@ -429,6 +445,15 @@ const makeSubGroupsUrl = (group: INamedObject) => { return `/groups?${filter.makeQueryParameters()}`; }; +const makeSceneMarkersSceneUrl = (scene: GQL.SceneMarkerSceneDataFragment) => { + if (!scene.id) return "#"; + const filter = new ListFilterModel(GQL.FilterMode.SceneMarkers, undefined); + const criterion = new MarkersScenesCriterion(); + criterion.value = [{ id: scene.id, label: objectTitle(scene) }]; + filter.criteria.push(criterion); + return `/scenes/markers?${filter.makeQueryParameters()}`; +}; + export function handleUnsavedChanges( intl: IntlShape, basepath: string, @@ -449,6 +474,7 @@ const NavUtils = { makePerformerImagesUrl, makePerformerGalleriesUrl, makePerformerGroupsUrl, + makePerformerSceneMarkersUrl, makePerformersCountryUrl, makeStudioScenesUrl, makeStudioImagesUrl, @@ -477,6 +503,7 @@ const NavUtils = { makeDirectorGroupsUrl, makeContainingGroupsUrl, makeSubGroupsUrl, + makeSceneMarkersSceneUrl, }; export default NavUtils;