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;