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 | 51 +++-- 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, 479 insertions(+), 28 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 251c2af838c..31218577df3 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 101cc8ba5e5..644732be94e 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) - } + 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 - } + s, err := sqb.Find(ctx, marker.SceneID) - if s == nil { - return fmt.Errorf("scene with id %d not found", 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 + } } - 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 e2ebfc4df34..a5dbc8a6c7c 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 3b1de35c7b2..766e318fc6a 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 00000000000..01d0722261b --- /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 eb8f2c10425..143daca4f96 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 00000000000..b18e1fa544a --- /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 00000000000..6532f535a97 --- /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 2bf7ae8dba5..33ae79558bd 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 ca1d051cd02..bb50236ecb6 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 c0127b5db33..00d129be749 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 89500419f53..1d9e344ebd8 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 143632af005..ac477d188a6 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 a70cd16291e..fa7d4e71ae7 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 4b4b2bf69a7..f6712fb58fb 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;