From 5734ee43ff788d12c2caa604de2bfa18358fb269 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 26 Feb 2026 07:54:40 +1100 Subject: [PATCH] Add sidebar to scene markers list (#6603) * Add tag markers filter * Add marker count and markers filter to performer filter * Add sidebar to marker list --- graphql/schema/types/filters.graphql | 6 + pkg/models/performer.go | 4 + pkg/models/tag.go | 2 + pkg/sqlite/criterion_handlers.go | 13 +- pkg/sqlite/performer_filter.go | 27 + pkg/sqlite/tag_filter.go | 14 + .../List/Filters/LabeledIdFilter.tsx | 19 + .../src/components/Scenes/SceneMarkerList.tsx | 538 ++++++++++++++---- .../Tags/TagDetails/TagMarkersPanel.tsx | 4 +- 9 files changed, 504 insertions(+), 123 deletions(-) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 907e597f4..d9814ef34 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -177,6 +177,8 @@ input PerformerFilterType { tag_count: IntCriterionInput "Filter by scene count" scene_count: IntCriterionInput + "Filter by marker count (via scene)" + marker_count: IntCriterionInput "Filter by image count" image_count: IntCriterionInput "Filter by gallery count" @@ -220,6 +222,8 @@ input PerformerFilterType { galleries_filter: GalleryFilterType "Filter by related tags that meet this criteria" tags_filter: TagFilterType + "Filter by related scene markers (via scene) that meet this criteria" + markers_filter: SceneMarkerFilterType "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" @@ -684,6 +688,8 @@ input TagFilterType { performers_filter: PerformerFilterType "Filter by related studios that meet this criteria" studios_filter: StudioFilterType + "Filter by related scene markers that meet this criteria" + markers_filter: SceneMarkerFilterType "Filter by creation time" created_at: TimestampCriterionInput diff --git a/pkg/models/performer.go b/pkg/models/performer.go index e4fb8dd98..8de5d94f4 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -158,6 +158,8 @@ type PerformerFilterType struct { TagCount *IntCriterionInput `json:"tag_count"` // Filter by scene count SceneCount *IntCriterionInput `json:"scene_count"` + // Filter by scene marker count (via scene) + MarkerCount *IntCriterionInput `json:"marker_count"` // Filter by image count ImageCount *IntCriterionInput `json:"image_count"` // Filter by gallery count @@ -202,6 +204,8 @@ type PerformerFilterType struct { GalleriesFilter *GalleryFilterType `json:"galleries_filter"` // Filter by related tags that meet this criteria TagsFilter *TagFilterType `json:"tags_filter"` + // Filter by related scene markers (via scene) that meet this criteria + MarkersFilter *SceneMarkerFilterType `json:"markers_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 3a133dcad..b166e5a69 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -56,6 +56,8 @@ type TagFilterType struct { PerformersFilter *PerformerFilterType `json:"performers_filter"` // Filter by related studios that meet this criteria StudiosFilter *StudioFilterType `json:"studios_filter"` + // Filter by related scene markers that meet this criteria + MarkersFilter *SceneMarkerFilterType `json:"markers_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/sqlite/criterion_handlers.go b/pkg/sqlite/criterion_handlers.go index 1496df71d..943704cfe 100644 --- a/pkg/sqlite/criterion_handlers.go +++ b/pkg/sqlite/criterion_handlers.go @@ -1089,11 +1089,16 @@ func (h *stashIDsCriterionHandler) handle(ctx context.Context, f *filterBuilder) } type relatedFilterHandler struct { - relatedIDCol string - relatedRepo repository + // column on the primary table that relates to the related table (eg scene_id) + relatedIDCol string + // repository for the related table (eg sceneRepository) + relatedRepo repository + // handler for the filter on the related table relatedHandler criterionHandler - joinFn func(f *filterBuilder) - directJoin bool + // optional function to perform the necessary join(s) to the related table + joinFn func(f *filterBuilder) + // if true, related filter handler will be run using the existing filterBuilder instead of a subquery. + directJoin bool } func (h *relatedFilterHandler) handle(ctx context.Context, f *filterBuilder) { diff --git a/pkg/sqlite/performer_filter.go b/pkg/sqlite/performer_filter.go index 5296d5a25..e99f3068f 100644 --- a/pkg/sqlite/performer_filter.go +++ b/pkg/sqlite/performer_filter.go @@ -195,6 +195,7 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler { qb.tagCountCriterionHandler(filter.TagCount), qb.sceneCountCriterionHandler(filter.SceneCount), + qb.markerCountCriterionHandler(filter.MarkerCount), qb.imageCountCriterionHandler(filter.ImageCount), qb.galleryCountCriterionHandler(filter.GalleryCount), qb.playCounterCriterionHandler(filter.PlayCount), @@ -204,6 +205,16 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler { ×tampCriterionHandler{filter.CreatedAt, tableName + ".created_at", nil}, ×tampCriterionHandler{filter.UpdatedAt, tableName + ".updated_at", nil}, + &relatedFilterHandler{ + relatedIDCol: "scene_markers.id", + relatedRepo: sceneMarkerRepository.repository, + relatedHandler: &sceneMarkerFilterHandler{filter.MarkersFilter}, + joinFn: func(f *filterBuilder) { + performerRepository.scenes.innerJoin(f, "", "performers.id") + f.addInnerJoin(sceneMarkerTable, "", "scene_markers.scene_id = performers_scenes.scene_id") + }, + }, + &relatedFilterHandler{ relatedIDCol: "performers_scenes.scene_id", relatedRepo: sceneRepository.repository, @@ -387,6 +398,22 @@ func (qb *performerFilterHandler) sceneCountCriterionHandler(count *models.IntCr return h.handler(count) } +func (qb *performerFilterHandler) markerCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if count != nil { + performerRepository.scenes.innerJoin(f, "", "performers.id") + + const query = `(SELECT COUNT(*) FROM scene_markers + INNER JOIN scenes ON scene_markers.scene_id = scenes.id + INNER JOIN performers_scenes ON performers_scenes.scene_id = scenes.id + WHERE performers_scenes.performer_id = performers.id)` + + clause, args := getIntCriterionWhereClause(query, *count) + f.addWhere(clause, args...) + } + } +} + func (qb *performerFilterHandler) imageCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: performerTable, diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index b3a7c1756..4e2313080 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -161,6 +161,20 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { tagRepository.studios.innerJoin(f, "", "tags.id") }, }, + + &relatedFilterHandler{ + relatedIDCol: "markers_tags.marker_id", + relatedRepo: sceneMarkerRepository.repository, + relatedHandler: &sceneMarkerFilterHandler{tagFilter.MarkersFilter}, + joinFn: func(f *filterBuilder) { + f.addWith(`markers_tags AS ( + SELECT mt.scene_marker_id AS marker_id, mt.tag_id AS tag_id FROM scene_markers_tags mt + UNION + SELECT m.id, m.primary_tag_id FROM scene_markers m + )`) + f.addInnerJoin("markers_tags", "", "markers_tags.tag_id = tags.id") + }, + }, } } diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index a9163578f..f19472d64 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -24,6 +24,7 @@ import { IntCriterionInput, PerformerFilterType, SceneFilterType, + SceneMarkerFilterType, StudioFilterType, } from "src/core/generated-graphql"; import { useIntl } from "react-intl"; @@ -527,6 +528,8 @@ interface IFilterType { group_count?: InputMaybe; studios_filter?: InputMaybe; studio_count?: InputMaybe; + marker_count?: InputMaybe; + markers_filter?: InputMaybe; } export function setObjectFilter( @@ -549,6 +552,7 @@ export function setObjectFilter( modifier: CriterionModifier.GreaterThan, value: 0, }; + break; } out.scenes_filter = relatedFilterOutput as SceneFilterType; break; @@ -559,6 +563,7 @@ export function setObjectFilter( modifier: CriterionModifier.GreaterThan, value: 0, }; + break; } out.performers_filter = relatedFilterOutput as PerformerFilterType; break; @@ -569,6 +574,7 @@ export function setObjectFilter( modifier: CriterionModifier.GreaterThan, value: 0, }; + break; } out.galleries_filter = relatedFilterOutput as GalleryFilterType; break; @@ -579,6 +585,7 @@ export function setObjectFilter( modifier: CriterionModifier.GreaterThan, value: 0, }; + break; } out.groups_filter = relatedFilterOutput as GroupFilterType; break; @@ -589,9 +596,21 @@ export function setObjectFilter( modifier: CriterionModifier.GreaterThan, value: 0, }; + break; } out.studios_filter = relatedFilterOutput as StudioFilterType; break; + case FilterMode.SceneMarkers: + // if empty, only get objects with scene markers + if (empty) { + out.marker_count = { + modifier: CriterionModifier.GreaterThan, + value: 0, + }; + break; + } + out.markers_filter = relatedFilterOutput as SceneMarkerFilterType; + break; default: throw new Error("Invalid filter mode"); } diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index b5975ca5a..781a3f0b2 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -1,7 +1,7 @@ import cloneDeep from "lodash-es/cloneDeep"; -import React from "react"; +import React, { useCallback, useEffect } from "react"; import { useHistory } from "react-router-dom"; -import { useIntl } from "react-intl"; +import { FormattedMessage, useIntl } from "react-intl"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { @@ -9,7 +9,7 @@ import { useFindSceneMarkers, } from "src/core/StashService"; import NavUtils from "src/utils/navigation"; -import { ItemList, ItemListContext } from "../List/ItemList"; +import { useFilteredItemList } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { MarkerWallPanel } from "./SceneMarkerWallPanel"; @@ -17,17 +17,179 @@ import { View } from "../List/views"; import { SceneMarkerCardGrid } from "./SceneMarkerCardGrid"; import { DeleteSceneMarkersDialog } from "./DeleteSceneMarkersDialog"; import { EditSceneMarkersDialog } from "./EditSceneMarkersDialog"; -import { PatchComponent } from "src/patch"; -import { IItemListOperation } from "../List/FilteredListToolbar"; +import { PatchComponent, PatchContainerComponent } from "src/patch"; +import { + FilteredListToolbar, + IItemListOperation, +} from "../List/FilteredListToolbar"; +import { + Sidebar, + SidebarPane, + SidebarPaneContent, + SidebarStateContext, + useSidebarState, +} from "../Shared/Sidebar"; +import { useCloseEditDelete, useFilterOperations } from "../List/util"; +import { + FilteredSidebarHeader, + useFilteredSidebarKeybinds, +} from "../List/Filters/FilterSidebar"; +import { useZoomKeybinds } from "../List/ZoomSlider"; +import { + IListFilterOperation, + ListOperations, +} from "../List/ListOperationButtons"; +import cx from "classnames"; +import { FilterTags } from "../List/FilterTags"; +import { Pagination, PaginationIndex } from "../List/Pagination"; +import { LoadedContent } from "../List/PagedList"; +import useFocus from "src/utils/focus"; +import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter"; +import { SidebarTagsFilter } from "../List/Filters/TagsFilter"; +import { Button } from "react-bootstrap"; -function getItems(result: GQL.FindSceneMarkersQueryResult) { - return result?.data?.findSceneMarkers?.scene_markers ?? []; +const SceneMarkerList: React.FC<{ + markers: GQL.SceneMarkerDataFragment[]; + filter: ListFilterModel; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +}> = PatchComponent( + "SceneList", + ({ markers, filter, selectedIds, onSelectChange }) => { + if (markers.length === 0) { + return null; + } + + if (filter.displayMode === DisplayMode.Wall) { + return ( + + ); + } + + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + + return null; + } +); + +function usePlayRandom(filter: ListFilterModel, count: number) { + const history = useHistory(); + + const playRandom = useCallback(async () => { + // query for a random scene + if (count === 0) { + return; + } + + const pages = Math.ceil(count / filter.itemsPerPage); + const page = Math.floor(Math.random() * pages) + 1; + + const indexMax = Math.min(filter.itemsPerPage, count); + const index = Math.floor(Math.random() * indexMax); + const filterCopy = cloneDeep(filter); + filterCopy.currentPage = page; + filterCopy.sortBy = "random"; + const queryResults = await queryFindSceneMarkers(filterCopy); + const marker = queryResults.data.findSceneMarkers.scene_markers[index]; + if (marker) { + // navigate to the scene player page + const url = NavUtils.makeSceneMarkerUrl(marker); + history.push(url); + } + }, [filter, count, history]); + + return playRandom; } -function getCount(result: GQL.FindSceneMarkersQueryResult) { - return result?.data?.findSceneMarkers?.count ?? 0; +function useAddKeybinds(filter: ListFilterModel, count: number) { + const playRandom = usePlayRandom(filter, count); + + useEffect(() => { + Mousetrap.bind("p r", () => { + playRandom(); + }); + + return () => { + Mousetrap.unbind("p r"); + }; + }, [playRandom]); } +const ScenesFilterSidebarSections = PatchContainerComponent( + "FilteredSceneMarkerList.SidebarSections" +); + +const SidebarContent: React.FC<{ + filter: ListFilterModel; + setFilter: (filter: ListFilterModel) => void; + filterHook?: (filter: ListFilterModel) => ListFilterModel; + view?: View; + sidebarOpen: boolean; + onClose?: () => void; + showEditFilter: (editingCriterion?: string) => void; + count?: number; + focus?: ReturnType; +}> = ({ + filter, + setFilter, + filterHook, + view, + showEditFilter, + sidebarOpen, + onClose, + count, + focus, +}) => { + const showResultsId = + count !== undefined ? "actions.show_count_results" : "actions.show_results"; + + return ( + <> + + + + + + + +
+ +
+ + ); +}; + interface ISceneMarkerList { filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; @@ -36,132 +198,274 @@ interface ISceneMarkerList { extraOperations?: IItemListOperation[]; } -export const SceneMarkerList: React.FC = PatchComponent( - "SceneMarkerList", - ({ filterHook, view, alterQuery, extraOperations = [] }) => { +export const FilteredSceneMarkerList = PatchComponent( + "FilteredSceneMarkerList", + (props: ISceneMarkerList) => { const intl = useIntl(); - const history = useHistory(); - const filterMode = GQL.FilterMode.SceneMarkers; + const searchFocus = useFocus(); - const otherOperations = [ - ...extraOperations, - { - text: intl.formatMessage({ id: "actions.play_random" }), - onClick: playRandom, - }, - ]; + const { + filterHook, + defaultSort, + view, + alterQuery, + extraOperations = [], + } = props; - function addKeybinds( - result: GQL.FindSceneMarkersQueryResult, - filter: ListFilterModel - ) { - Mousetrap.bind("p r", () => { - playRandom(result, filter); + // States + const { + showSidebar, + setShowSidebar, + loading: sidebarStateLoading, + sectionOpen, + setSectionOpen, + } = useSidebarState(view); + + const { filterState, queryResult, modalState, listSelect, showEditFilter } = + useFilteredItemList({ + filterStateProps: { + filterMode: GQL.FilterMode.SceneMarkers, + defaultSort, + view, + useURL: alterQuery, + }, + queryResultProps: { + useResult: useFindSceneMarkers, + getCount: (r) => r.data?.findSceneMarkers.count ?? 0, + getItems: (r) => r.data?.findSceneMarkers.scene_markers ?? [], + filterHook, + }, + }); + + const { filter, setFilter } = filterState; + + const { effectiveFilter, result, cachedResult, items, totalCount } = + queryResult; + + const { + selectedIds, + selectedItems, + onSelectChange, + onSelectAll, + onSelectNone, + onInvertSelection, + hasSelection, + } = listSelect; + + const { modal, showModal, closeModal } = modalState; + + // Utility hooks + const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ + filter, + setFilter, + }); + + useAddKeybinds(filter, totalCount); + useFilteredSidebarKeybinds({ + showSidebar, + setShowSidebar, + }); + + const onCloseEditDelete = useCloseEditDelete({ + closeModal, + onSelectNone, + result, + }); + + const onEdit = useCallback(() => { + showModal( + + ); + }, [showModal, selectedItems, onCloseEditDelete]); + + const onDelete = useCallback(() => { + showModal( + + ); + }, [showModal, selectedItems, onCloseEditDelete]); + + useEffect(() => { + Mousetrap.bind("e", () => { + if (hasSelection) { + onEdit?.(); + } + }); + + Mousetrap.bind("d d", () => { + if (hasSelection) { + onDelete?.(); + } }); return () => { - Mousetrap.unbind("p r"); + Mousetrap.unbind("e"); + Mousetrap.unbind("d d"); }; - } + }, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]); - async function playRandom( - result: GQL.FindSceneMarkersQueryResult, - filter: ListFilterModel - ) { - // query for a random scene - if (result.data?.findSceneMarkers) { - const { count } = result.data.findSceneMarkers; + useZoomKeybinds({ + zoomIndex: filter.zoomIndex, + onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)), + }); - const index = Math.floor(Math.random() * count); - const filterCopy = cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindSceneMarkers(filterCopy); - if (singleResult.data.findSceneMarkers.scene_markers.length === 1) { - // navigate to the scene player page - const url = NavUtils.makeSceneMarkerUrl( - singleResult.data.findSceneMarkers.scene_markers[0] - ); - history.push(url); - } - } - } + const playRandom = usePlayRandom(effectiveFilter, totalCount); - function renderContent( - result: GQL.FindSceneMarkersQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void - ) { - if (!result.data?.findSceneMarkers) return; + const convertedExtraOperations: IListFilterOperation[] = + extraOperations.map((o) => ({ + ...o, + isDisplayed: o.isDisplayed + ? () => o.isDisplayed!(result, filter, selectedIds) + : undefined, + onClick: () => { + o.onClick(result, filter, selectedIds); + }, + })); - if (filter.displayMode === DisplayMode.Wall) { - return ( - - ); - } + const otherOperations = [ + ...convertedExtraOperations, + { + text: intl.formatMessage({ id: "actions.select_all" }), + onClick: () => onSelectAll(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.select_none" }), + onClick: () => onSelectNone(), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.invert_selection" }), + onClick: () => onInvertSelection(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.play_random" }), + onClick: playRandom, + isDisplayed: () => totalCount > 1, + }, + // { + // text: `${intl.formatMessage({ id: "actions.generate" })}…`, + // onClick: () => + // showModal( + // closeModal()} + // /> + // ), + // isDisplayed: () => hasSelection, + // }, + ]; - if (filter.displayMode === DisplayMode.Grid) { - return ( - - ); - } - } + // render + if (sidebarStateLoading) return null; - function renderEditDialog( - selectedMarkers: GQL.SceneMarkerDataFragment[], - onClose: (applied: boolean) => void - ) { - return ( - - ); - } - - function renderDeleteDialog( - selectedSceneMarkers: GQL.SceneMarkerDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ( - - ); - } + const operations = ( + + ); return ( - - - + {modal} + + + + setShowSidebar(false)}> + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} + /> + + setShowSidebar(!showSidebar)} + > + + + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={removeCriterion} + onRemoveAll={clearAllCriteria} + /> + +
+ setFilter(filter.changePage(page))} + /> + +
+ + + + + + {totalCount > filter.itemsPerPage && ( +
+
+ +
+
+ )} +
+
+
+ ); } ); -export default SceneMarkerList; +export default FilteredSceneMarkerList; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx index f32f26497..63b906c80 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx @@ -5,7 +5,7 @@ import { TagsCriterion, TagsCriterionOption, } from "src/models/list-filter/criteria/tags"; -import { SceneMarkerList } from "src/components/Scenes/SceneMarkerList"; +import { FilteredSceneMarkerList } from "src/components/Scenes/SceneMarkerList"; import { View } from "src/components/List/views"; function useFilterHook(tag: GQL.TagDataFragment, showSubTagContent?: boolean) { @@ -60,7 +60,7 @@ export const TagMarkersPanel: React.FC = ({ const filterHook = useFilterHook(tag, showSubTagContent); return ( -