From d64b3b711cbc2d3fdb8131f8fa51fe1c30c7efb6 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:37:38 +1100 Subject: [PATCH] Revamp studio list with sidebar (#6549) * Add studios_filter to TagFilterType * Convert studio list to use sidebar --- graphql/schema/types/filters.graphql | 2 + pkg/models/tag.go | 2 + pkg/sqlite/tag.go | 9 + pkg/sqlite/tag_filter.go | 9 + .../List/Filters/LabeledIdFilter.tsx | 19 +- .../StudioDetails/StudioChildrenPanel.tsx | 4 +- ui/v2.5/src/components/Studios/StudioList.tsx | 565 +++++++++++++----- ui/v2.5/src/components/Studios/Studios.tsx | 4 +- .../Tags/TagDetails/TagStudiosPanel.tsx | 4 +- 9 files changed, 461 insertions(+), 157 deletions(-) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index f89cee3e2..04a28171c 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -644,6 +644,8 @@ input TagFilterType { galleries_filter: GalleryFilterType "Filter by related performers that meet this criteria" performers_filter: PerformerFilterType + "Filter by related studios that meet this criteria" + studios_filter: StudioFilterType "Filter by creation time" created_at: TimestampCriterionInput diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 0f39d8861..bfb3f1ad3 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -52,6 +52,8 @@ type TagFilterType struct { GalleriesFilter *GalleryFilterType `json:"galleries_filter"` // Filter by related performers that meet this criteria PerformersFilter *PerformerFilterType `json:"performers_filter"` + // Filter by related studios that meet this criteria + StudiosFilter *StudioFilterType `json:"studios_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index ea18664d9..8a0561b0f 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -108,6 +108,7 @@ type tagRepositoryType struct { images joinRepository galleries joinRepository performers joinRepository + studios joinRepository } var ( @@ -161,6 +162,14 @@ var ( fkColumn: performerIDColumn, foreignTable: performerTable, }, + studios: joinRepository{ + repository: repository{ + tableName: studiosTagsTable, + idColumn: tagIDColumn, + }, + fkColumn: studioIDColumn, + foreignTable: studioTable, + }, } ) diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index 2f4e79149..92da1237c 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -143,6 +143,15 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { tagRepository.performers.innerJoin(f, "", "tags.id") }, }, + + &relatedFilterHandler{ + relatedIDCol: "studios_tags.studio_id", + relatedRepo: studioRepository.repository, + relatedHandler: &studioFilterHandler{tagFilter.StudiosFilter}, + joinFn: func(f *filterBuilder) { + tagRepository.studios.innerJoin(f, "", "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 d621d85bd..a5e81087e 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -23,6 +23,7 @@ import { IntCriterionInput, PerformerFilterType, SceneFilterType, + StudioFilterType, } from "src/core/generated-graphql"; import { useIntl } from "react-intl"; @@ -521,12 +522,18 @@ interface IFilterType { performer_count?: InputMaybe; galleries_filter?: InputMaybe; gallery_count?: InputMaybe; + studios_filter?: InputMaybe; + studio_count?: InputMaybe; } export function setObjectFilter( out: IFilterType, mode: FilterMode, - relatedFilterOutput: SceneFilterType | PerformerFilterType | GalleryFilterType + relatedFilterOutput: + | SceneFilterType + | PerformerFilterType + | GalleryFilterType + | StudioFilterType ) { const empty = Object.keys(relatedFilterOutput).length === 0; @@ -561,6 +568,16 @@ export function setObjectFilter( } out.galleries_filter = relatedFilterOutput as GalleryFilterType; break; + case FilterMode.Studios: + // if empty, only get objects with studios + if (empty) { + out.studio_count = { + modifier: CriterionModifier.GreaterThan, + value: 0, + }; + } + out.studios_filter = relatedFilterOutput as StudioFilterType; + break; default: throw new Error("Invalid filter mode"); } diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx index b6cd8b484..a69364a89 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx @@ -2,7 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { ParentStudiosCriterion } from "src/models/list-filter/criteria/studios"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { StudioList } from "../StudioList"; +import { FilteredStudioList } from "../StudioList"; import { View } from "src/components/List/views"; function useFilterHook(studio: GQL.StudioDataFragment) { @@ -51,7 +51,7 @@ export const StudioChildrenPanel: React.FC = ({ const filterHook = useFilterHook(studio); return ( - ; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + fromParent?: boolean; +}> = PatchComponent( + "StudioList", + ({ studios, filter, selectedIds, onSelectChange, fromParent }) => { + if (studios.length === 0) { + return null; + } -function getCount(result: GQL.FindStudiosQueryResult) { - return result?.data?.findStudios?.count ?? 0; -} + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.List) { + return

TODO

; + } + if (filter.displayMode === DisplayMode.Wall) { + return

TODO

; + } + if (filter.displayMode === DisplayMode.Tagger) { + return ; + } + + return null; + } +); + +const StudioFilterSidebarSections = PatchContainerComponent( + "FilteredStudioList.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 ( + <> + + + + + + } + filter={filter} + setFilter={setFilter} + option={FavoriteStudioCriterionOption} + sectionID="favourite" + /> + + +
+ +
+ + ); +}; interface IStudioList { fromParent?: boolean; @@ -37,147 +157,172 @@ interface IStudioList { extraOperations?: IItemListOperation[]; } -export const StudioList: React.FC = PatchComponent( - "StudioList", - ({ fromParent, filterHook, view, alterQuery, extraOperations = [] }) => { +function useViewRandom(filter: ListFilterModel, count: number) { + const history = useHistory(); + + const viewRandom = useCallback(async () => { + // query for a random studio + if (count === 0) { + return; + } + + const index = Math.floor(Math.random() * count); + const filterCopy = cloneDeep(filter); + filterCopy.itemsPerPage = 1; + filterCopy.currentPage = index + 1; + const singleResult = await queryFindStudios(filterCopy); + if (singleResult.data.findStudios.studios.length === 1) { + const { id } = singleResult.data.findStudios.studios[0]; + // navigate to the studio page + history.push(`/studios/${id}`); + } + }, [history, filter, count]); + + return viewRandom; +} + +function useAddKeybinds(filter: ListFilterModel, count: number) { + const viewRandom = useViewRandom(filter, count); + + useEffect(() => { + Mousetrap.bind("p r", () => { + viewRandom(); + }); + + return () => { + Mousetrap.unbind("p r"); + }; + }, [viewRandom]); +} + +export const FilteredStudioList = PatchComponent( + "FilteredStudioList", + (props: IStudioList) => { const intl = useIntl(); const history = useHistory(); - const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); - const [isExportAll, setIsExportAll] = useState(false); + const location = useLocation(); - const filterMode = GQL.FilterMode.Studios; + const searchFocus = useFocus(); - const otherOperations = [ - ...extraOperations, - { - text: intl.formatMessage({ id: "actions.view_random" }), - onClick: viewRandom, - }, - { - text: intl.formatMessage({ id: "actions.export" }), - onClick: onExport, - isDisplayed: showWhenSelected, - }, - { - text: intl.formatMessage({ id: "actions.export_all" }), - onClick: onExportAll, - }, - ]; + const { filterHook, view, alterQuery, extraOperations = [] } = props; - function addKeybinds( - result: GQL.FindStudiosQueryResult, - filter: ListFilterModel - ) { - Mousetrap.bind("p r", () => { - viewRandom(result, filter); + // States + const { + showSidebar, + setShowSidebar, + sectionOpen, + setSectionOpen, + loading: sidebarStateLoading, + } = useSidebarState(view); + + const { filterState, queryResult, modalState, listSelect, showEditFilter } = + useFilteredItemList({ + filterStateProps: { + filterMode: GQL.FilterMode.Studios, + view, + useURL: alterQuery, + }, + queryResultProps: { + useResult: useFindStudios, + getCount: (r) => r.data?.findStudios.count ?? 0, + getItems: (r) => r.data?.findStudios.studios ?? [], + 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, + }); + + 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"); }; - } + }); - async function viewRandom( - result: GQL.FindStudiosQueryResult, - filter: ListFilterModel - ) { - // query for a random studio - if (result.data?.findStudios) { - const { count } = result.data.findStudios; + const onCloseEditDelete = useCloseEditDelete({ + closeModal, + onSelectNone, + result, + }); - const index = Math.floor(Math.random() * count); - const filterCopy = cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindStudios(filterCopy); - if (singleResult.data.findStudios.studios.length === 1) { - const { id } = singleResult.data.findStudios.studios[0]; - // navigate to the studio page - history.push(`/studios/${id}`); - } + function onCreateNew() { + let queryParam = new URLSearchParams(location.search).get("q"); + let newPath = "/studios/new"; + if (queryParam) { + newPath += "?q=" + encodeURIComponent(queryParam); } + history.push(newPath); } - async function onExport() { - setIsExportAll(false); - setIsExportDialogOpen(true); - } + const viewRandom = useViewRandom(filter, totalCount); - async function onExportAll() { - setIsExportAll(true); - setIsExportDialogOpen(true); - } - - function renderContent( - result: GQL.FindStudiosQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void - ) { - function maybeRenderExportDialog() { - if (isExportDialogOpen) { - return ( - setIsExportDialogOpen(false)} - /> - ); - } - } - - function renderStudios() { - if (!result.data?.findStudios) return; - - if (filter.displayMode === DisplayMode.Grid) { - return ( - - ); - } - if (filter.displayMode === DisplayMode.List) { - return

TODO

; - } - if (filter.displayMode === DisplayMode.Wall) { - return

TODO

; - } - if (filter.displayMode === DisplayMode.Tagger) { - return ; - } - } - - return ( - <> - {maybeRenderExportDialog()} - {renderStudios()} - + function onExport(all: boolean) { + showModal( + closeModal()} + /> ); } - function renderEditDialog( - selectedStudios: GQL.SlimStudioDataFragment[], - onClose: (applied: boolean) => void - ) { - return ; + function onEdit() { + showModal( + + ); } - function renderDeleteDialog( - selectedStudios: GQL.SlimStudioDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ( + function onDelete() { + showModal( = PatchComponent( ); } + const convertedExtraOperations = extraOperations.map((op) => ({ + text: op.text, + onClick: () => op.onClick(result, filter, selectedIds), + isDisplayed: () => op.isDisplayed?.(result, filter, selectedIds) ?? true, + })); + + 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.view_random" }), + onClick: viewRandom, + }, + { + text: intl.formatMessage({ id: "actions.export" }), + onClick: () => onExport(false), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.export_all" }), + onClick: () => onExport(true), + }, + ]; + + // render + if (sidebarStateLoading) return null; + + 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 && ( +
+
+ +
+
+ )} +
+
+
+ ); } ); diff --git a/ui/v2.5/src/components/Studios/Studios.tsx b/ui/v2.5/src/components/Studios/Studios.tsx index 545de936f..956531fe0 100644 --- a/ui/v2.5/src/components/Studios/Studios.tsx +++ b/ui/v2.5/src/components/Studios/Studios.tsx @@ -4,11 +4,11 @@ import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import Studio from "./StudioDetails/Studio"; import StudioCreate from "./StudioDetails/StudioCreate"; -import { StudioList } from "./StudioList"; +import { FilteredStudioList } from "./StudioList"; import { View } from "../List/views"; const Studios: React.FC = () => { - return ; + return ; }; const StudioRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx index 72d150b42..045d55481 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx @@ -1,7 +1,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useTagFilterHook } from "src/core/tags"; -import { StudioList } from "src/components/Studios/StudioList"; +import { FilteredStudioList } from "src/components/Studios/StudioList"; interface ITagStudiosPanel { active: boolean; @@ -15,5 +15,5 @@ export const TagStudiosPanel: React.FC = ({ showSubTagContent, }) => { const filterHook = useTagFilterHook(tag, showSubTagContent); - return ; + return ; };