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;