From d68d042bd73668b9d85daefddd0608f0fcf3e8e6 Mon Sep 17 00:00:00 2001 From: slick-daddy Date: Sat, 16 May 2026 05:27:43 +0300 Subject: [PATCH 01/11] Add click-to-sort column headers to list views --- .../src/components/Galleries/GalleryList.tsx | 36 ++++- .../components/Galleries/GalleryListTable.tsx | 14 ++ ui/v2.5/src/components/List/ListTable.tsx | 32 +++- .../components/Performers/PerformerList.tsx | 48 +++++- .../Performers/PerformerListTable.tsx | 19 +++ ui/v2.5/src/components/Scenes/SceneList.tsx | 151 ++++-------------- .../src/components/Scenes/SceneListTable.tsx | 14 ++ ui/v2.5/src/components/Studios/StudioList.tsx | 38 ++++- .../components/Studios/StudioListTable.tsx | 19 ++- ui/v2.5/src/components/Tags/TagList.tsx | 41 ++++- ui/v2.5/src/components/Tags/TagListTable.tsx | 10 ++ 11 files changed, 288 insertions(+), 134 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index abb2bdda81..ab25781e07 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from "react"; +import React, { useCallback, useEffect, useMemo } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import cloneDeep from "lodash-es/cloneDeep"; import { useHistory } from "react-router-dom"; @@ -54,14 +54,28 @@ import { PerformerAgeCriterionOption } from "src/models/list-filter/galleries"; import { SidebarFolderFilter } from "../List/Filters/FolderFilter"; import { ParentFolderCriterionOption } from "src/models/list-filter/criteria/folder"; +const galleryColumnSortMap: Record = { + images: "images_count", +}; + const GalleryList: React.FC<{ galleries: GQL.SlimGalleryDataFragment[]; filter: ListFilterModel; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + onSort?: (value: string) => void; }> = PatchComponent( "GalleryList", - ({ galleries, filter, selectedIds, onSelectChange }) => { + ({ galleries, filter, selectedIds, onSelectChange, onSort }) => { + const reverseSortMap = useMemo(() => { + const rev: Record = {}; + Object.entries(galleryColumnSortMap).forEach(([k, v]) => { + rev[v] = k; + }); + return rev; + }, []); + const activeSortColumn = reverseSortMap[filter.sortBy] ?? filter.sortBy; + if (galleries.length === 0) { return null; } @@ -82,6 +96,9 @@ const GalleryList: React.FC<{ galleries={galleries} selectedIds={selectedIds} onSelectChange={onSelectChange} + onSort={onSort} + sortBy={activeSortColumn} + sortDirection={filter.sortDirection} /> ); } @@ -280,6 +297,20 @@ export const FilteredGalleryList = PatchComponent( const { filter, setFilter } = filterState; + const onSort = useCallback( + (value: string) => { + const backendField = galleryColumnSortMap[value] ?? value; + if (filter.sortBy === backendField) { + setFilter(filter.toggleSortDirection()); + } else { + const newFilter = filter.setSortBy(backendField); + newFilter.sortDirection = GQL.SortDirectionEnum.Asc; + setFilter(newFilter); + } + }, + [filter, setFilter] + ); + const { effectiveFilter, result, cachedResult, items, totalCount } = queryResult; @@ -504,6 +535,7 @@ export const FilteredGalleryList = PatchComponent( galleries={items} selectedIds={selectedIds} onSelectChange={onSelectChange} + onSort={onSort} /> diff --git a/ui/v2.5/src/components/Galleries/GalleryListTable.tsx b/ui/v2.5/src/components/Galleries/GalleryListTable.tsx index 017083b11d..ce0ea05914 100644 --- a/ui/v2.5/src/components/Galleries/GalleryListTable.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryListTable.tsx @@ -14,6 +14,9 @@ interface IGalleryListTableProps { galleries: GQL.SlimGalleryDataFragment[]; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + onSort?: (value: string) => void; + sortBy?: string; + sortDirection?: string; } const TABLE_NAME = "galleries"; @@ -147,6 +150,7 @@ export const GalleryListTable: React.FC = ( label: string; defaultShow?: boolean; mandatory?: boolean; + sortable?: boolean; render?: ( gallery: GQL.SlimGalleryDataFragment, index: number @@ -158,6 +162,7 @@ export const GalleryListTable: React.FC = ( value: "cover_image", label: intl.formatMessage({ id: "cover_image" }), defaultShow: true, + sortable: false, render: CoverImageCell, }, { @@ -182,6 +187,7 @@ export const GalleryListTable: React.FC = ( { value: "code", label: intl.formatMessage({ id: "scene_code" }), + sortable: false, render: (s) => <>{s.code}, }, { @@ -194,29 +200,34 @@ export const GalleryListTable: React.FC = ( value: "tags", label: intl.formatMessage({ id: "tags" }), defaultShow: true, + sortable: false, render: TagCell, }, { value: "performers", label: intl.formatMessage({ id: "performers" }), defaultShow: true, + sortable: false, render: PerformersCell, }, { value: "studio", label: intl.formatMessage({ id: "studio" }), defaultShow: true, + sortable: false, render: StudioCell, }, { value: "scenes", label: intl.formatMessage({ id: "scenes" }), defaultShow: true, + sortable: false, render: SceneCell, }, { value: "photographer", label: intl.formatMessage({ id: "photographer" }), + sortable: false, render: (s) => <>{s.photographer}, }, { @@ -265,6 +276,9 @@ export const GalleryListTable: React.FC = ( selectedIds={props.selectedIds} onSelectChange={props.onSelectChange} renderCell={renderCell} + onSort={props.onSort} + sortBy={props.sortBy} + sortDirection={props.sortDirection} /> ); }; diff --git a/ui/v2.5/src/components/List/ListTable.tsx b/ui/v2.5/src/components/List/ListTable.tsx index e583c54b8c..a6252c2d19 100644 --- a/ui/v2.5/src/components/List/ListTable.tsx +++ b/ui/v2.5/src/components/List/ListTable.tsx @@ -7,6 +7,7 @@ export interface IColumn { label: string; value: string; mandatory?: boolean; + sortable?: boolean; } export const ColumnSelector: React.FC<{ @@ -47,6 +48,9 @@ interface IListTableProps { selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; renderCell: (column: IColumn, item: T, index: number) => React.ReactNode; + onSort?: (value: string) => void; + sortBy?: string; + sortDirection?: string; } export const ListTable = ( @@ -61,6 +65,9 @@ export const ListTable = ( selectedIds, onSelectChange, renderCell, + onSort, + sortBy, + sortDirection, } = props; const visibleColumns = useMemo(() => { @@ -102,12 +109,25 @@ export const ListTable = ( }; const columnHeaders = useMemo(() => { - return visibleColumns.map((column) => ( - - {column.label} - - )); - }, [visibleColumns]); + const arrow = sortDirection === "ASC" ? " \u25B2" : " \u25BC"; + + return visibleColumns.map((column) => { + const isSortable = column.sortable !== false && onSort; + const isActive = sortBy === column.value; + + return ( + onSort(column.value) : undefined} + style={isSortable ? { cursor: "pointer" } : undefined} + > + {column.label} + {isActive && arrow} + + ); + }); + }, [visibleColumns, onSort, sortBy, sortDirection]); return (
diff --git a/ui/v2.5/src/components/Performers/PerformerList.tsx b/ui/v2.5/src/components/Performers/PerformerList.tsx index c2db288bcd..c349a1b3c5 100644 --- a/ui/v2.5/src/components/Performers/PerformerList.tsx +++ b/ui/v2.5/src/components/Performers/PerformerList.tsx @@ -1,5 +1,5 @@ import cloneDeep from "lodash-es/cloneDeep"; -import React, { useCallback, useEffect } from "react"; +import React, { useCallback, useEffect, useMemo } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; @@ -191,6 +191,15 @@ export const FormatPenisLength = (penis_length?: number | null) => { ); }; +const performerColumnSortMap: Record = { + height_cm: "height", + penis_length_cm: "penis_length", + scene_count: "scenes_count", + gallery_count: "galleries_count", + image_count: "images_count", + age: "birthdate", +}; + interface IPerformerList { filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; @@ -205,9 +214,26 @@ const PerformerList: React.FC<{ selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; extraCriteria?: IPerformerCardExtraCriteria; + onSort?: (value: string) => void; }> = PatchComponent( "PerformerList", - ({ performers, filter, selectedIds, onSelectChange, extraCriteria }) => { + ({ + performers, + filter, + selectedIds, + onSelectChange, + extraCriteria, + onSort, + }) => { + const reverseSortMap = useMemo(() => { + const rev: Record = {}; + Object.entries(performerColumnSortMap).forEach(([k, v]) => { + rev[v] = k; + }); + return rev; + }, []); + const activeSortColumn = reverseSortMap[filter.sortBy] ?? filter.sortBy; + if (performers.length === 0 && filter.displayMode !== DisplayMode.Tagger) { return null; } @@ -229,6 +255,9 @@ const PerformerList: React.FC<{ performers={performers} selectedIds={selectedIds} onSelectChange={onSelectChange} + onSort={onSort} + sortBy={activeSortColumn} + sortDirection={filter.sortDirection} /> ); } @@ -403,6 +432,20 @@ export const FilteredPerformerList = PatchComponent( const { filter, setFilter } = filterState; + const onSort = useCallback( + (value: string) => { + const backendField = performerColumnSortMap[value] ?? value; + if (filter.sortBy === backendField) { + setFilter(filter.toggleSortDirection()); + } else { + const newFilter = filter.setSortBy(backendField); + newFilter.sortDirection = GQL.SortDirectionEnum.Asc; + setFilter(newFilter); + } + }, + [filter, setFilter] + ); + const { effectiveFilter, result, cachedResult, items, totalCount } = queryResult; @@ -636,6 +679,7 @@ export const FilteredPerformerList = PatchComponent( selectedIds={selectedIds} onSelectChange={onSelectChange} extraCriteria={extraCriteria} + onSort={onSort} /> diff --git a/ui/v2.5/src/components/Performers/PerformerListTable.tsx b/ui/v2.5/src/components/Performers/PerformerListTable.tsx index 5f61ea0319..72fd4145ea 100644 --- a/ui/v2.5/src/components/Performers/PerformerListTable.tsx +++ b/ui/v2.5/src/components/Performers/PerformerListTable.tsx @@ -25,6 +25,9 @@ interface IPerformerListTableProps { performers: GQL.PerformerDataFragment[]; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + onSort?: (value: string) => void; + sortBy?: string; + sortDirection?: string; } const TABLE_NAME = "performers"; @@ -217,6 +220,7 @@ export const PerformerListTable: React.FC = ( label: string; defaultShow?: boolean; mandatory?: boolean; + sortable?: boolean; render?: ( scene: GQL.PerformerDataFragment, index: number @@ -228,6 +232,7 @@ export const PerformerListTable: React.FC = ( value: "image", label: intl.formatMessage({ id: "image" }), defaultShow: true, + sortable: false, render: ImageCell, }, { @@ -241,12 +246,14 @@ export const PerformerListTable: React.FC = ( value: "aliases", label: intl.formatMessage({ id: "aliases" }), defaultShow: true, + sortable: false, render: AliasesCell, }, { value: "gender", label: intl.formatMessage({ id: "gender" }), defaultShow: true, + sortable: false, render: GenderCell, }, { @@ -264,34 +271,40 @@ export const PerformerListTable: React.FC = ( { value: "death_date", label: intl.formatMessage({ id: "death_date" }), + sortable: false, render: DeathdateCell, }, { value: "favourite", label: intl.formatMessage({ id: "favourite" }), defaultShow: true, + sortable: false, render: FavoriteCell, }, { value: "country", label: intl.formatMessage({ id: "country" }), defaultShow: true, + sortable: false, render: CountryCell, }, { value: "ethnicity", label: intl.formatMessage({ id: "ethnicity" }), defaultShow: true, + sortable: false, render: EthnicityCell, }, { value: "hair_color", label: intl.formatMessage({ id: "hair_color" }), + sortable: false, render: HairColorCell, }, { value: "eye_color", label: intl.formatMessage({ id: "eye_color" }), + sortable: false, render: EyeColorCell, }, { @@ -312,6 +325,7 @@ export const PerformerListTable: React.FC = ( { value: "circumcised", label: intl.formatMessage({ id: "circumcised" }), + sortable: false, render: CircumcisedCell, }, { @@ -322,12 +336,14 @@ export const PerformerListTable: React.FC = ( { value: "fake_tits", label: intl.formatMessage({ id: "fake_tits" }), + sortable: false, render: FakeTitsCell, }, { value: "career_length", label: intl.formatMessage({ id: "career_length" }), defaultShow: true, + sortable: false, render: CareerLengthCell, }, { @@ -395,6 +411,9 @@ export const PerformerListTable: React.FC = ( selectedIds={props.selectedIds} onSelectChange={props.onSelectChange} renderCell={renderCell} + onSort={props.onSort} + sortBy={props.sortBy} + sortDirection={props.sortDirection} /> ); }; diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 21722a1dbe..e8fe2dde3c 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -60,124 +60,9 @@ import { FilteredListToolbar } from "../List/FilteredListToolbar"; import { FilterTags } from "../List/FilterTags"; import { SidebarFolderFilter } from "../List/Filters/FolderFilter"; -function renderMetadataByline(result: GQL.FindScenesQueryResult) { - const duration = result?.data?.findScenes?.duration; - const size = result?.data?.findScenes?.filesize; - - if (!duration && !size) { - return; - } - - const separator = duration && size ? " - " : ""; - - return ( - -  ( - {duration ? ( - - {TextUtils.secondsAsTimeString(duration, 3)} - - ) : undefined} - {separator} - {size ? ( - - - - ) : undefined} - ) - - ); -} - -function usePlayScene() { - const history = useHistory(); - - const { configuration: config } = useConfigurationContext(); - const cont = config?.interface.continuePlaylistDefault ?? false; - const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false; - - const playScene = useCallback( - (queue: SceneQueue, sceneID: string, options?: IPlaySceneOptions) => { - history.push( - queue.makeLink(sceneID, { autoPlay, continue: cont, ...options }) - ); - }, - [history, cont, autoPlay] - ); - - return playScene; -} - -function usePlaySelected(selectedIds: Set) { - const playScene = usePlayScene(); - - const playSelected = useCallback(() => { - // populate queue and go to first scene - const sceneIDs = Array.from(selectedIds.values()); - const queue = SceneQueue.fromSceneIDList(sceneIDs); - - playScene(queue, sceneIDs[0]); - }, [selectedIds, playScene]); - - return playSelected; -} - -function usePlayFirst() { - const playScene = usePlayScene(); - - const playFirst = useCallback( - (queue: SceneQueue, sceneID: string, index: number) => { - // populate queue and go to first scene - playScene(queue, sceneID, { sceneIndex: index }); - }, - [playScene] - ); - - return playFirst; -} - -function usePlayRandom(filter: ListFilterModel, count: number) { - const playScene = usePlayScene(); - - 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 queryFindScenes(filterCopy); - const scene = queryResults.data.findScenes.scenes[index]; - if (scene) { - // navigate to the image player page - const queue = SceneQueue.fromListFilterModel(filterCopy); - playScene(queue, scene.id, { sceneIndex: index }); - } - }, [filter, count, playScene]); - - return playRandom; -} - -function useAddKeybinds(filter: ListFilterModel, count: number) { - const playRandom = usePlayRandom(filter, count); - - useEffect(() => { - Mousetrap.bind("p r", () => { - playRandom(); - }); - - return () => { - Mousetrap.unbind("p r"); - }; - }, [playRandom]); -} +const sceneColumnSortMap: Record = { + scene_code: "code", +}; const SceneList: React.FC<{ scenes: GQL.SlimSceneDataFragment[]; @@ -185,9 +70,19 @@ const SceneList: React.FC<{ selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; fromGroupId?: string; + onSort?: (value: string) => void; }> = PatchComponent( "SceneList", - ({ scenes, filter, selectedIds, onSelectChange, fromGroupId }) => { + ({ scenes, filter, selectedIds, onSelectChange, fromGroupId, onSort }) => { + const reverseSortMap = useMemo(() => { + const rev: Record = {}; + Object.entries(sceneColumnSortMap).forEach(([k, v]) => { + rev[v] = k; + }); + return rev; + }, []); + const activeSortColumn = reverseSortMap[filter.sortBy] ?? filter.sortBy; + const queue = useMemo( () => SceneQueue.fromListFilterModel(filter), [filter] @@ -216,6 +111,9 @@ const SceneList: React.FC<{ queue={queue} selectedIds={selectedIds} onSelectChange={onSelectChange} + onSort={onSort} + sortBy={activeSortColumn} + sortDirection={filter.sortDirection} /> ); } @@ -398,6 +296,20 @@ export const FilteredSceneList = PatchComponent( const { filter, setFilter } = filterState; + const onSort = useCallback( + (value: string) => { + const backendField = sceneColumnSortMap[value] ?? value; + if (filter.sortBy === backendField) { + setFilter(filter.toggleSortDirection()); + } else { + const newFilter = filter.setSortBy(backendField); + newFilter.sortDirection = GQL.SortDirectionEnum.Asc; + setFilter(newFilter); + } + }, + [filter, setFilter] + ); + const { effectiveFilter, result, cachedResult, items, totalCount } = queryResult; @@ -710,6 +622,7 @@ export const FilteredSceneList = PatchComponent( selectedIds={selectedIds} onSelectChange={onSelectChange} fromGroupId={fromGroupId} + onSort={onSort} /> diff --git a/ui/v2.5/src/components/Scenes/SceneListTable.tsx b/ui/v2.5/src/components/Scenes/SceneListTable.tsx index d5cf65f5d9..6b9bc22ac8 100644 --- a/ui/v2.5/src/components/Scenes/SceneListTable.tsx +++ b/ui/v2.5/src/components/Scenes/SceneListTable.tsx @@ -18,6 +18,9 @@ interface ISceneListTableProps { queue?: SceneQueue; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + onSort?: (value: string) => void; + sortBy?: string; + sortDirection?: string; } const TABLE_NAME = "scenes"; @@ -249,6 +252,7 @@ export const SceneListTable: React.FC = ( label: string; defaultShow?: boolean; mandatory?: boolean; + sortable?: boolean; render?: ( scene: GQL.SlimSceneDataFragment, index: number @@ -260,6 +264,7 @@ export const SceneListTable: React.FC = ( value: "cover_image", label: intl.formatMessage({ id: "cover_image" }), defaultShow: true, + sortable: false, render: CoverImageCell, }, { @@ -302,24 +307,28 @@ export const SceneListTable: React.FC = ( value: "performers", label: intl.formatMessage({ id: "performers" }), defaultShow: true, + sortable: false, render: PerformersCell, }, { value: "tags", label: intl.formatMessage({ id: "tags" }), defaultShow: true, + sortable: false, render: TagCell, }, { value: "groups", label: intl.formatMessage({ id: "groups" }), defaultShow: true, + sortable: false, render: GroupCell, }, { value: "galleries", label: intl.formatMessage({ id: "galleries" }), defaultShow: true, + sortable: false, render: GalleriesCell, }, { @@ -365,11 +374,13 @@ export const SceneListTable: React.FC = ( { value: "video_codec", label: intl.formatMessage({ id: "video_codec" }), + sortable: false, render: VideoCodecCell, }, { value: "audio_codec", label: intl.formatMessage({ id: "audio_codec" }), + sortable: false, render: AudioCodecCell, }, ]; @@ -413,6 +424,9 @@ export const SceneListTable: React.FC = ( selectedIds={props.selectedIds} onSelectChange={props.onSelectChange} renderCell={renderCell} + onSort={props.onSort} + sortBy={props.sortBy} + sortDirection={props.sortDirection} /> ); }; diff --git a/ui/v2.5/src/components/Studios/StudioList.tsx b/ui/v2.5/src/components/Studios/StudioList.tsx index 478c632c84..fb1e22914f 100644 --- a/ui/v2.5/src/components/Studios/StudioList.tsx +++ b/ui/v2.5/src/components/Studios/StudioList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from "react"; +import React, { useCallback, useEffect, useMemo } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import cloneDeep from "lodash-es/cloneDeep"; import { useHistory } from "react-router-dom"; @@ -48,15 +48,31 @@ import { FavoriteStudioCriterionOption } from "src/models/list-filter/criteria/f import { Button } from "react-bootstrap"; import cx from "classnames"; +const studioColumnSortMap: Record = { + scene_count: "scenes_count", + image_count: "images_count", + gallery_count: "galleries_count", +}; + const StudioList: React.FC<{ studios: GQL.StudioDataFragment[]; filter: ListFilterModel; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; fromParent?: boolean; + onSort?: (value: string) => void; }> = PatchComponent( "StudioList", - ({ studios, filter, selectedIds, onSelectChange, fromParent }) => { + ({ studios, filter, selectedIds, onSelectChange, fromParent, onSort }) => { + const reverseSortMap = useMemo(() => { + const rev: Record = {}; + Object.entries(studioColumnSortMap).forEach(([k, v]) => { + rev[v] = k; + }); + return rev; + }, []); + const activeSortColumn = reverseSortMap[filter.sortBy] ?? filter.sortBy; + if (studios.length === 0 && filter.displayMode !== DisplayMode.Tagger) { return null; } @@ -78,6 +94,9 @@ const StudioList: React.FC<{ studios={studios} selectedIds={selectedIds} onSelectChange={onSelectChange} + onSort={onSort} + sortBy={activeSortColumn} + sortDirection={filter.sortDirection} /> ); } @@ -237,6 +256,20 @@ export const FilteredStudioList = PatchComponent( const { filter, setFilter } = filterState; + const onSort = useCallback( + (value: string) => { + const backendField = studioColumnSortMap[value] ?? value; + if (filter.sortBy === backendField) { + setFilter(filter.toggleSortDirection()); + } else { + const newFilter = filter.setSortBy(backendField); + newFilter.sortDirection = GQL.SortDirectionEnum.Asc; + setFilter(newFilter); + } + }, + [filter, setFilter] + ); + const { effectiveFilter, result, cachedResult, items, totalCount } = queryResult; @@ -444,6 +477,7 @@ export const FilteredStudioList = PatchComponent( studios={items} selectedIds={selectedIds} onSelectChange={onSelectChange} + onSort={onSort} /> diff --git a/ui/v2.5/src/components/Studios/StudioListTable.tsx b/ui/v2.5/src/components/Studios/StudioListTable.tsx index fc9c2b2867..84fbe66c26 100644 --- a/ui/v2.5/src/components/Studios/StudioListTable.tsx +++ b/ui/v2.5/src/components/Studios/StudioListTable.tsx @@ -13,6 +13,9 @@ interface IStudioListTableProps { studios: GQL.StudioDataFragment[]; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + onSort?: (value: string) => void; + sortBy?: string; + sortDirection?: string; } const TABLE_NAME = "studios"; @@ -21,6 +24,9 @@ export const StudioListTable: React.FC = ({ studios, selectedIds, onSelectChange, + onSort, + sortBy, + sortDirection, }) => { const intl = useIntl(); const [updateStudio] = useStudioUpdate(); @@ -111,7 +117,11 @@ export const StudioListTable: React.FC = ({ const childLink = studio.child_studios && studio.child_studios.length > 0 ? ( - {studio.child_studios.length} + {studio.child_studios.length}{" "} + {intl.formatMessage( + { id: "studios" }, + { count: studio.child_studios.length } + )} ) : null; return ( @@ -128,6 +138,7 @@ export const StudioListTable: React.FC = ({ label: string; defaultShow?: boolean; mandatory?: boolean; + sortable?: boolean; render?: (studio: GQL.StudioDataFragment, index: number) => React.ReactNode; } @@ -136,6 +147,7 @@ export const StudioListTable: React.FC = ({ value: "image", label: intl.formatMessage({ id: "image" }), defaultShow: true, + sortable: false, render: ImageCell, }, { @@ -149,6 +161,7 @@ export const StudioListTable: React.FC = ({ value: "aliases", label: intl.formatMessage({ id: "aliases" }), defaultShow: true, + sortable: false, render: AliasesCell, }, { @@ -185,6 +198,7 @@ export const StudioListTable: React.FC = ({ value: "related", label: intl.formatMessage({ id: "related_studios" }), defaultShow: true, + sortable: false, render: RelatedCell, }, ]; @@ -227,6 +241,9 @@ export const StudioListTable: React.FC = ({ selectedIds={selectedIds} onSelectChange={onSelectChange} renderCell={renderCell} + onSort={onSort} + sortBy={sortBy} + sortDirection={sortDirection} /> ); }; diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index 2627504248..3865488b45 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from "react"; +import React, { useCallback, useEffect, useMemo } from "react"; import cloneDeep from "lodash-es/cloneDeep"; import Mousetrap from "mousetrap"; import { ListFilterModel } from "src/models/list-filter/filter"; @@ -48,14 +48,33 @@ import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; import { FavoriteTagCriterionOption } from "src/models/list-filter/criteria/favorite"; import { TagListTable } from "./TagListTable"; +const tagColumnSortMap: Record = { + scene_count: "scenes_count", + gallery_count: "galleries_count", + image_count: "images_count", + group_count: "groups_count", + performer_count: "performers_count", + studio_count: "studios_count", +}; + const TagList: React.FC<{ tags: GQL.TagListDataFragment[]; filter: ListFilterModel; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + onSort?: (value: string) => void; }> = PatchComponent( "TagList", - ({ tags, filter, selectedIds, onSelectChange }) => { + ({ tags, filter, selectedIds, onSelectChange, onSort }) => { + const reverseSortMap = useMemo(() => { + const rev: Record = {}; + Object.entries(tagColumnSortMap).forEach(([k, v]) => { + rev[v] = k; + }); + return rev; + }, []); + const activeSortColumn = reverseSortMap[filter.sortBy] ?? filter.sortBy; + if (tags.length === 0 && filter.displayMode !== DisplayMode.Tagger) { return null; } @@ -76,6 +95,9 @@ const TagList: React.FC<{ tags={tags} selectedIds={selectedIds} onSelectChange={onSelectChange} + onSort={onSort} + sortBy={activeSortColumn} + sortDirection={filter.sortDirection} /> ); } @@ -232,6 +254,20 @@ export const FilteredTagList = PatchComponent( const { filter, setFilter } = filterState; + const onSort = useCallback( + (value: string) => { + const backendField = tagColumnSortMap[value] ?? value; + if (filter.sortBy === backendField) { + setFilter(filter.toggleSortDirection()); + } else { + const newFilter = filter.setSortBy(backendField); + newFilter.sortDirection = GQL.SortDirectionEnum.Asc; + setFilter(newFilter); + } + }, + [filter, setFilter] + ); + const { effectiveFilter, result, cachedResult, items, totalCount } = queryResult; @@ -467,6 +503,7 @@ export const FilteredTagList = PatchComponent( tags={items} selectedIds={selectedIds} onSelectChange={onSelectChange} + onSort={onSort} /> diff --git a/ui/v2.5/src/components/Tags/TagListTable.tsx b/ui/v2.5/src/components/Tags/TagListTable.tsx index bc7f440dae..f667917b86 100644 --- a/ui/v2.5/src/components/Tags/TagListTable.tsx +++ b/ui/v2.5/src/components/Tags/TagListTable.tsx @@ -17,6 +17,9 @@ interface ITagListTableProps { tags: GQL.TagListDataFragment[]; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + onSort?: (value: string) => void; + sortBy?: string; + sortDirection?: string; } const TABLE_NAME = "tags"; @@ -119,6 +122,7 @@ export const TagListTable: React.FC = ( label: string; defaultShow?: boolean; mandatory?: boolean; + sortable?: boolean; render?: (tag: GQL.TagListDataFragment, index: number) => React.ReactNode; } @@ -127,6 +131,7 @@ export const TagListTable: React.FC = ( value: "image", label: intl.formatMessage({ id: "image" }), defaultShow: true, + sortable: false, render: ImageCell, }, { @@ -140,12 +145,14 @@ export const TagListTable: React.FC = ( value: "aliases", label: intl.formatMessage({ id: "aliases" }), defaultShow: true, + sortable: false, render: AliasesCell, }, { value: "favourite", label: intl.formatMessage({ id: "favourite" }), defaultShow: true, + sortable: false, render: FavoriteCell, }, { @@ -225,6 +232,9 @@ export const TagListTable: React.FC = ( selectedIds={props.selectedIds} onSelectChange={props.onSelectChange} renderCell={renderCell} + onSort={props.onSort} + sortBy={props.sortBy} + sortDirection={props.sortDirection} /> ); }; From f061d848c498ce2cebfa3301ef630c39940d8f65 Mon Sep 17 00:00:00 2001 From: slick-daddy Date: Sat, 16 May 2026 05:31:33 +0300 Subject: [PATCH 02/11] Fix missing useAddKeybinds and TypeScript sortBy index error in list views --- .../src/components/Galleries/GalleryList.tsx | 3 +- .../components/Performers/PerformerList.tsx | 3 +- ui/v2.5/src/components/Scenes/SceneList.tsx | 118 +++++++++++++++++- ui/v2.5/src/components/Studios/StudioList.tsx | 7 +- ui/v2.5/src/components/Tags/TagList.tsx | 3 +- 5 files changed, 127 insertions(+), 7 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index ab25781e07..ebbf5c64dd 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -74,7 +74,8 @@ const GalleryList: React.FC<{ }); return rev; }, []); - const activeSortColumn = reverseSortMap[filter.sortBy] ?? filter.sortBy; + const activeSortColumn = + reverseSortMap[filter.sortBy ?? ""] ?? filter.sortBy; if (galleries.length === 0) { return null; diff --git a/ui/v2.5/src/components/Performers/PerformerList.tsx b/ui/v2.5/src/components/Performers/PerformerList.tsx index c349a1b3c5..3bb3162c0d 100644 --- a/ui/v2.5/src/components/Performers/PerformerList.tsx +++ b/ui/v2.5/src/components/Performers/PerformerList.tsx @@ -232,7 +232,8 @@ const PerformerList: React.FC<{ }); return rev; }, []); - const activeSortColumn = reverseSortMap[filter.sortBy] ?? filter.sortBy; + const activeSortColumn = + reverseSortMap[filter.sortBy ?? ""] ?? filter.sortBy; if (performers.length === 0 && filter.displayMode !== DisplayMode.Tagger) { return null; diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index e8fe2dde3c..958dd88f15 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -60,6 +60,121 @@ import { FilteredListToolbar } from "../List/FilteredListToolbar"; import { FilterTags } from "../List/FilterTags"; import { SidebarFolderFilter } from "../List/Filters/FolderFilter"; +function renderMetadataByline(result: GQL.FindScenesQueryResult) { + const duration = result?.data?.findScenes?.duration; + const size = result?.data?.findScenes?.filesize; + + if (!duration && !size) { + return; + } + + const separator = duration && size ? " - " : ""; + + return ( + +  ( + {duration ? ( + + {TextUtils.secondsAsTimeString(duration, 3)} + + ) : undefined} + {separator} + {size ? ( + + + + ) : undefined} + ) + + ); +} + +function usePlayScene() { + const history = useHistory(); + + const { configuration: config } = useConfigurationContext(); + const cont = config?.interface.continuePlaylistDefault ?? false; + const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false; + + const playScene = useCallback( + (queue: SceneQueue, sceneID: string, options?: IPlaySceneOptions) => { + history.push( + queue.makeLink(sceneID, { autoPlay, continue: cont, ...options }) + ); + }, + [history, cont, autoPlay] + ); + + return playScene; +} + +function usePlaySelected(selectedIds: Set) { + const playScene = usePlayScene(); + + const playSelected = useCallback(() => { + const sceneIDs = Array.from(selectedIds.values()); + const queue = SceneQueue.fromSceneIDList(sceneIDs); + + playScene(queue, sceneIDs[0]); + }, [selectedIds, playScene]); + + return playSelected; +} + +function usePlayFirst() { + const playScene = usePlayScene(); + + const playFirst = useCallback( + (queue: SceneQueue, sceneID: string, index: number) => { + playScene(queue, sceneID, { sceneIndex: index }); + }, + [playScene] + ); + + return playFirst; +} + +function usePlayRandom(filter: ListFilterModel, count: number) { + const playScene = usePlayScene(); + + const playRandom = useCallback(async () => { + 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 queryFindScenes(filterCopy); + const scene = queryResults.data.findScenes.scenes[index]; + if (scene) { + const queue = SceneQueue.fromListFilterModel(filterCopy); + playScene(queue, scene.id, { sceneIndex: index }); + } + }, [filter, count, playScene]); + + return playRandom; +} + +function useAddKeybinds(filter: ListFilterModel, count: number) { + const playRandom = usePlayRandom(filter, count); + + useEffect(() => { + Mousetrap.bind("p r", () => { + playRandom(); + }); + + return () => { + Mousetrap.unbind("p r"); + }; + }, [playRandom]); +} + const sceneColumnSortMap: Record = { scene_code: "code", }; @@ -81,7 +196,8 @@ const SceneList: React.FC<{ }); return rev; }, []); - const activeSortColumn = reverseSortMap[filter.sortBy] ?? filter.sortBy; + const activeSortColumn = + reverseSortMap[filter.sortBy ?? ""] ?? filter.sortBy; const queue = useMemo( () => SceneQueue.fromListFilterModel(filter), diff --git a/ui/v2.5/src/components/Studios/StudioList.tsx b/ui/v2.5/src/components/Studios/StudioList.tsx index fb1e22914f..ef769ddff0 100644 --- a/ui/v2.5/src/components/Studios/StudioList.tsx +++ b/ui/v2.5/src/components/Studios/StudioList.tsx @@ -71,7 +71,8 @@ const StudioList: React.FC<{ }); return rev; }, []); - const activeSortColumn = reverseSortMap[filter.sortBy] ?? filter.sortBy; + const activeSortColumn = + reverseSortMap[filter.sortBy ?? ""] ?? filter.sortBy; if (studios.length === 0 && filter.displayMode !== DisplayMode.Tagger) { return null; @@ -94,8 +95,8 @@ const StudioList: React.FC<{ studios={studios} selectedIds={selectedIds} onSelectChange={onSelectChange} - onSort={onSort} - sortBy={activeSortColumn} + onSort={onSort} + sortBy={activeSortColumn} sortDirection={filter.sortDirection} /> ); diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index 3865488b45..52677ad323 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -73,7 +73,8 @@ const TagList: React.FC<{ }); return rev; }, []); - const activeSortColumn = reverseSortMap[filter.sortBy] ?? filter.sortBy; + const activeSortColumn = + reverseSortMap[filter.sortBy ?? ""] ?? filter.sortBy; if (tags.length === 0 && filter.displayMode !== DisplayMode.Tagger) { return null; From 8f638cfa30d1d2fde19c0561bab5d46d779fe7b6 Mon Sep 17 00:00:00 2001 From: slick-daddy Date: Sat, 13 Jun 2026 16:25:05 +0300 Subject: [PATCH 03/11] Address PR review: use SortByIcon, SortDirectionEnum type, flex layout, CSS padding fix --- .../components/Galleries/GalleryListTable.tsx | 11 ++++---- ui/v2.5/src/components/List/ListFilter.tsx | 24 ++++++++++------- ui/v2.5/src/components/List/ListTable.tsx | 26 +++++++++++++------ ui/v2.5/src/components/List/styles.scss | 8 ++++-- .../Performers/PerformerListTable.tsx | 13 +++++----- .../src/components/Scenes/SceneListTable.tsx | 11 ++++---- .../components/Studios/StudioListTable.tsx | 9 ++++--- ui/v2.5/src/components/Tags/TagListTable.tsx | 9 ++++--- 8 files changed, 67 insertions(+), 44 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryListTable.tsx b/ui/v2.5/src/components/Galleries/GalleryListTable.tsx index ce0ea05914..fbcf987258 100644 --- a/ui/v2.5/src/components/Galleries/GalleryListTable.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryListTable.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; +import { SortDirectionEnum } from "src/core/generated-graphql"; import NavUtils from "src/utils/navigation"; import { useIntl } from "react-intl"; import { objectTitle } from "src/core/files"; @@ -16,13 +17,13 @@ interface IGalleryListTableProps { onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; onSort?: (value: string) => void; sortBy?: string; - sortDirection?: string; + sortDirection?: SortDirectionEnum; } const TABLE_NAME = "galleries"; export const GalleryListTable: React.FC = ( - props: IGalleryListTableProps + props: IGalleryListTableProps, ) => { const intl = useIntl(); @@ -153,7 +154,7 @@ export const GalleryListTable: React.FC = ( sortable?: boolean; render?: ( gallery: GQL.SlimGalleryDataFragment, - index: number + index: number, ) => React.ReactNode; } @@ -243,7 +244,7 @@ export const GalleryListTable: React.FC = ( const { selectedColumns, saveColumns } = useTableColumns( TABLE_NAME, - defaultColumns + defaultColumns, ); const columnRenderFuncs: Record< @@ -259,7 +260,7 @@ export const GalleryListTable: React.FC = ( function renderCell( column: IColumn, gallery: GQL.SlimGalleryDataFragment, - index: number + index: number, ) { const render = columnRenderFuncs[column.value]; diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index f6979e1a8e..5514643307 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -37,7 +37,7 @@ import { useConfigurationContext } from "src/hooks/Config"; export function useDebouncedSearchInput( filter: ListFilterModel, - setFilter: (filter: ListFilterModel) => void + setFilter: (filter: ListFilterModel) => void, ) { const callback = useCallback( (value: string) => { @@ -46,7 +46,7 @@ export function useDebouncedSearchInput( newFilter.currentPage = 1; setFilter(newFilter); }, - [filter, setFilter] + [filter, setFilter], ); const onClear = useCallback(() => callback(""), [callback]); @@ -74,7 +74,7 @@ export const SearchTermInput: React.FC<{ const { searchCallback, onClear } = useDebouncedSearchInput( filter, - onFilterUpdate + onFilterUpdate, ); useEffect(() => { @@ -202,7 +202,7 @@ export const PageSizeSelector: React.FC<{ onKeyPress={(e: React.KeyboardEvent) => { if (e.key === "Enter") { onChangePageSize( - (perPageInput.current as HTMLInputElement)?.value ?? "" + (perPageInput.current as HTMLInputElement)?.value ?? "", ); e.preventDefault(); } @@ -213,7 +213,7 @@ export const PageSizeSelector: React.FC<{ variant="primary" onClick={() => onChangePageSize( - (perPageInput.current as HTMLInputElement)?.value ?? "" + (perPageInput.current as HTMLInputElement)?.value ?? "", ) } > @@ -228,6 +228,14 @@ export const PageSizeSelector: React.FC<{ ); }; +export const SortByIcon: React.FC<{ + sortDirection: SortDirectionEnum; +}> = ({ sortDirection }) => ( + +); + export const SortBySelect: React.FC<{ className?: string; sortBy: string | undefined; @@ -303,11 +311,7 @@ export const SortBySelect: React.FC<{ } > {sortBy === "random" && ( diff --git a/ui/v2.5/src/components/List/ListTable.tsx b/ui/v2.5/src/components/List/ListTable.tsx index a6252c2d19..66f322c9ea 100644 --- a/ui/v2.5/src/components/List/ListTable.tsx +++ b/ui/v2.5/src/components/List/ListTable.tsx @@ -2,6 +2,8 @@ import React, { useMemo } from "react"; import { Table, Form } from "react-bootstrap"; import { CheckBoxSelect } from "../Shared/Select"; import cx from "classnames"; +import { SortDirectionEnum } from "src/core/generated-graphql"; +import { SortByIcon } from "./ListFilter"; export interface IColumn { label: string; @@ -50,11 +52,11 @@ interface IListTableProps { renderCell: (column: IColumn, item: T, index: number) => React.ReactNode; onSort?: (value: string) => void; sortBy?: string; - sortDirection?: string; + sortDirection?: SortDirectionEnum; } export const ListTable = ( - props: IListTableProps + props: IListTableProps, ) => { const { className, @@ -72,7 +74,7 @@ export const ListTable = ( const visibleColumns = useMemo(() => { return allColumns.filter( - (col) => col.mandatory || columns.includes(col.value) + (col) => col.mandatory || columns.includes(col.value), ); }, [columns, allColumns]); @@ -90,7 +92,7 @@ export const ListTable = ( onSelectChange(item.id, !selectedIds.has(item.id), shiftKey) } onClick={( - event: React.MouseEvent + event: React.MouseEvent, ) => { shiftKey = event.shiftKey; event.stopPropagation(); @@ -109,8 +111,6 @@ export const ListTable = ( }; const columnHeaders = useMemo(() => { - const arrow = sortDirection === "ASC" ? " \u25B2" : " \u25BC"; - return visibleColumns.map((column) => { const isSortable = column.sortable !== false && onSort; const isActive = sortBy === column.value; @@ -122,8 +122,18 @@ export const ListTable = ( onClick={isSortable ? () => onSort(column.value) : undefined} style={isSortable ? { cursor: "pointer" } : undefined} > - {column.label} - {isActive && arrow} +
+ {column.label} + {isActive && sortDirection && ( + + )} +
); }); diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index d1183a60fd..3383a8016e 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -444,8 +444,11 @@ input[type="range"].zoom-slider { margin-right: 0.25rem; padding: 0.25em 0.6em; text-align: center; - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, - border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + transition: + color 0.15s ease-in-out, + background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out; vertical-align: baseline; white-space: nowrap; @@ -928,6 +931,7 @@ ul.selectable-list { .table thead th { border: none; + padding-bottom: 0; white-space: nowrap; } diff --git a/ui/v2.5/src/components/Performers/PerformerListTable.tsx b/ui/v2.5/src/components/Performers/PerformerListTable.tsx index 72fd4145ea..469c950809 100644 --- a/ui/v2.5/src/components/Performers/PerformerListTable.tsx +++ b/ui/v2.5/src/components/Performers/PerformerListTable.tsx @@ -3,6 +3,7 @@ import { useIntl } from "react-intl"; import { Button } from "react-bootstrap"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; +import { SortDirectionEnum } from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; import NavUtils from "src/utils/navigation"; import { faHeart } from "@fortawesome/free-solid-svg-icons"; @@ -27,13 +28,13 @@ interface IPerformerListTableProps { onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; onSort?: (value: string) => void; sortBy?: string; - sortDirection?: string; + sortDirection?: SortDirectionEnum; } const TABLE_NAME = "performers"; export const PerformerListTable: React.FC = ( - props: IPerformerListTableProps + props: IPerformerListTableProps, ) => { const intl = useIntl(); @@ -136,7 +137,7 @@ export const PerformerListTable: React.FC = ( + ); + const RelatedCell = (studio: GQL.StudioDataFragment) => { const parentLink = studio.parent_studio ? ( @@ -171,6 +197,12 @@ export const StudioListTable: React.FC = ({ defaultShow: true, render: RatingCell, }, + { + value: "favourite", + label: intl.formatMessage({ id: "favourite" }), + defaultShow: true, + render: FavoriteCell, + }, { value: "scene_count", label: intl.formatMessage({ id: "scenes" }), diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index 4ca1ebbee2..b7b2f1048b 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -56,6 +56,7 @@ const tagColumnSortMap: Record = { group_count: "groups_count", performer_count: "performers_count", studio_count: "studios_count", + favourite: "favorite", }; const TagList: React.FC<{ diff --git a/ui/v2.5/src/components/Tags/TagListTable.tsx b/ui/v2.5/src/components/Tags/TagListTable.tsx index 5193590972..4aae9430b7 100644 --- a/ui/v2.5/src/components/Tags/TagListTable.tsx +++ b/ui/v2.5/src/components/Tags/TagListTable.tsx @@ -153,7 +153,6 @@ export const TagListTable: React.FC = ( value: "favourite", label: intl.formatMessage({ id: "favourite" }), defaultShow: true, - sortable: false, render: FavoriteCell, }, { From f2035594329372ebade70d7581ca2ecea4aa507b Mon Sep 17 00:00:00 2001 From: slick-daddy Date: Tue, 16 Jun 2026 06:48:06 +0300 Subject: [PATCH 08/11] Fix NULLs sorting first for all nullable sort fields SQLite sorts NULLs first for both ASC and DESC. Add IS NULL ordering clause to push NULL values to the end in all sort directions. - getSort: add IS NULL to default, name, title, and filesize cases - performer: sortByOCounter, sortByLastPlayedAt, sortByLastOAt, sortByLatestScene - group: sortByOCounter - studio: sortByLatestScene - scene: last_played_at, last_o_at --- pkg/sqlite/group.go | 2 +- pkg/sqlite/performer.go | 8 ++++---- pkg/sqlite/scene.go | 4 ++-- pkg/sqlite/sql.go | 8 ++++---- pkg/sqlite/studio.go | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/sqlite/group.go b/pkg/sqlite/group.go index a20abd6ac6..e0ac67fddf 100644 --- a/pkg/sqlite/group.go +++ b/pkg/sqlite/group.go @@ -728,5 +728,5 @@ func (qb *GroupStore) FindInAncestors(ctx context.Context, ascestorIDs []int, id func (qb *GroupStore) sortByOCounter(direction string) string { // need to sum the o_counter from scenes and images - return " ORDER BY (" + selectGroupOCountSQL + ") " + direction + return " ORDER BY (" + selectGroupOCountSQL + ") IS NULL, (" + selectGroupOCountSQL + ") " + direction } diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 85517d8742..22dcc267ca 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -682,7 +682,7 @@ func (qb *PerformerStore) QueryCount(ctx context.Context, performerFilter *model func (qb *PerformerStore) sortByOCounter(direction string) string { // need to sum the o_counter from scenes and images - return " ORDER BY (" + selectPerformerOCountSQL + ") " + direction + return " ORDER BY (" + selectPerformerOCountSQL + ") IS NULL, (" + selectPerformerOCountSQL + ") " + direction } func (qb *PerformerStore) sortByPlayCount(direction string) string { @@ -711,7 +711,7 @@ var selectPerformerLastOAtSQL = utils.StrFormat( func (qb *PerformerStore) sortByLastOAt(direction string) string { // need to get the o_dates from scenes - return " ORDER BY (" + selectPerformerLastOAtSQL + ") " + direction + return " ORDER BY (" + selectPerformerLastOAtSQL + ") IS NULL, (" + selectPerformerLastOAtSQL + ") " + direction } // used for sorting on performer latest scene @@ -733,7 +733,7 @@ var selectPerformerLatestSceneSQL = utils.StrFormat( func (qb *PerformerStore) sortByLatestScene(direction string) string { // need to get the latest date from scenes - return " ORDER BY (" + selectPerformerLatestSceneSQL + ") " + direction + return " ORDER BY (" + selectPerformerLatestSceneSQL + ") IS NULL, (" + selectPerformerLatestSceneSQL + ") " + direction } // used for sorting on performer last view_date @@ -757,7 +757,7 @@ var selectPerformerLastPlayedAtSQL = utils.StrFormat( func (qb *PerformerStore) sortByLastPlayedAt(direction string) string { // need to get the view_dates from scenes - return " ORDER BY (" + selectPerformerLastPlayedAtSQL + ") " + direction + return " ORDER BY (" + selectPerformerLastPlayedAtSQL + ") IS NULL, (" + selectPerformerLastPlayedAtSQL + ") " + direction } // used for sorting by total scene duration diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 42e07ec208..72439dc52b 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -1264,9 +1264,9 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF case "play_count": query.sortAndPagination += getCountSort(sceneTable, scenesViewDatesTable, sceneIDColumn, direction) case "last_played_at": - query.sortAndPagination += fmt.Sprintf(" ORDER BY (SELECT MAX(view_date) FROM %s AS sort WHERE sort.%s = %s.id) %s", scenesViewDatesTable, sceneIDColumn, sceneTable, getSortDirection(direction)) + query.sortAndPagination += fmt.Sprintf(" ORDER BY (SELECT MAX(view_date) FROM %s AS sort WHERE sort.%s = %s.id) IS NULL, (SELECT MAX(view_date) FROM %s AS sort WHERE sort.%s = %s.id) %s", scenesViewDatesTable, sceneIDColumn, sceneTable, scenesViewDatesTable, sceneIDColumn, sceneTable, getSortDirection(direction)) case "last_o_at": - query.sortAndPagination += fmt.Sprintf(" ORDER BY (SELECT MAX(o_date) FROM %s AS sort WHERE sort.%s = %s.id) %s", scenesODatesTable, sceneIDColumn, sceneTable, getSortDirection(direction)) + query.sortAndPagination += fmt.Sprintf(" ORDER BY (SELECT MAX(o_date) FROM %s AS sort WHERE sort.%s = %s.id) IS NULL, (SELECT MAX(o_date) FROM %s AS sort WHERE sort.%s = %s.id) %s", scenesODatesTable, sceneIDColumn, sceneTable, scenesODatesTable, sceneIDColumn, sceneTable, getSortDirection(direction)) case "o_counter": query.sortAndPagination += getCountSort(sceneTable, scenesODatesTable, sceneIDColumn, direction) case "performer_age": diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 34cae84f66..6e36e28f41 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -99,7 +99,7 @@ func getSort(sort string, direction string, tableName string) string { return " ORDER BY COUNT(distinct " + colName + ") " + direction case strings.Compare(sort, "filesize") == 0: colName := getColumn(tableName, "size") - return " ORDER BY " + colName + " " + direction + return " ORDER BY " + colName + " IS NULL, " + colName + " " + direction case strings.HasPrefix(sort, randomSeedPrefix): // seed as a parameter from the UI seedStr := sort[len(randomSeedPrefix):] @@ -117,13 +117,13 @@ func getSort(sort string, direction string, tableName string) string { colName = sort } if strings.Compare(sort, "name") == 0 { - return " ORDER BY " + colName + " COLLATE NATURAL_CI " + direction + return " ORDER BY " + colName + " IS NULL, " + colName + " COLLATE NATURAL_CI " + direction } if strings.Compare(sort, "title") == 0 { - return " ORDER BY " + colName + " COLLATE NATURAL_CI " + direction + return " ORDER BY " + colName + " IS NULL, " + colName + " COLLATE NATURAL_CI " + direction } - return " ORDER BY " + colName + " " + direction + return " ORDER BY " + colName + " IS NULL, " + colName + " " + direction } } diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 760bf2f56a..30215385ea 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -655,7 +655,7 @@ var selectStudioLatestSceneSQL = utils.StrFormat( func (qb *StudioStore) sortByLatestScene(direction string) string { // need to get the latest date from scenes - return " ORDER BY (" + selectStudioLatestSceneSQL + ") " + direction + return " ORDER BY (" + selectStudioLatestSceneSQL + ") IS NULL, (" + selectStudioLatestSceneSQL + ") " + direction } var studioSortOptions = sortOptions{ From bffc2930cf3ef695b4825a406faaa7a9875486f0 Mon Sep 17 00:00:00 2001 From: slick-daddy Date: Tue, 16 Jun 2026 06:55:57 +0300 Subject: [PATCH 09/11] Fix age sort direction and revert broad getSort null change - Revert IS NULL addition in getSort (caused unintended side effects) - Add targeted birthdate case in getPerformerSort that flips direction (birthdate is inverse to age: lower date = older, so ASC exchanges with DESC) and adds IS NULL for nulls-last behavior --- pkg/sqlite/performer.go | 11 +++++++++++ pkg/sqlite/sql.go | 8 ++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 22dcc267ca..0a516434de 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -872,6 +872,17 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (s sortQuery += qb.sortByLastOAt(direction) case "latest_scene": sortQuery += qb.sortByLatestScene(direction) + case "birthdate": + // birthdate is inversely related to age (older = lower date). + // flip direction so DESC means "oldest first". + colName := getColumn(performerTable, "birthdate") + dir := getSortDirection(direction) + if dir == "ASC" { + dir = "DESC" + } else { + dir = "ASC" + } + sortQuery += " ORDER BY " + colName + " IS NULL, " + colName + " " + dir default: sortQuery += getSort(sort, direction, "performers") } diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 6e36e28f41..34cae84f66 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -99,7 +99,7 @@ func getSort(sort string, direction string, tableName string) string { return " ORDER BY COUNT(distinct " + colName + ") " + direction case strings.Compare(sort, "filesize") == 0: colName := getColumn(tableName, "size") - return " ORDER BY " + colName + " IS NULL, " + colName + " " + direction + return " ORDER BY " + colName + " " + direction case strings.HasPrefix(sort, randomSeedPrefix): // seed as a parameter from the UI seedStr := sort[len(randomSeedPrefix):] @@ -117,13 +117,13 @@ func getSort(sort string, direction string, tableName string) string { colName = sort } if strings.Compare(sort, "name") == 0 { - return " ORDER BY " + colName + " IS NULL, " + colName + " COLLATE NATURAL_CI " + direction + return " ORDER BY " + colName + " COLLATE NATURAL_CI " + direction } if strings.Compare(sort, "title") == 0 { - return " ORDER BY " + colName + " IS NULL, " + colName + " COLLATE NATURAL_CI " + direction + return " ORDER BY " + colName + " COLLATE NATURAL_CI " + direction } - return " ORDER BY " + colName + " IS NULL, " + colName + " " + direction + return " ORDER BY " + colName + " " + direction } } From 89287e8fd10fcb635dfec0381527d9b2e8223d9b Mon Sep 17 00:00:00 2001 From: slick-daddy Date: Fri, 26 Jun 2026 13:18:35 +0300 Subject: [PATCH 10/11] Revert NULLs-last sort changes from sqlite package These IS NULL ORDER BY changes were server-side SQL behavior improvements that don't belong in the click-to-sort column headers PR. The sort options arrays (favorite, birthdate handling) are kept since they directly enable the clickable column feature. --- pkg/sqlite/group.go | 2 +- pkg/sqlite/performer.go | 8 ++++---- pkg/sqlite/scene.go | 4 ++-- pkg/sqlite/studio.go | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/sqlite/group.go b/pkg/sqlite/group.go index e0ac67fddf..a20abd6ac6 100644 --- a/pkg/sqlite/group.go +++ b/pkg/sqlite/group.go @@ -728,5 +728,5 @@ func (qb *GroupStore) FindInAncestors(ctx context.Context, ascestorIDs []int, id func (qb *GroupStore) sortByOCounter(direction string) string { // need to sum the o_counter from scenes and images - return " ORDER BY (" + selectGroupOCountSQL + ") IS NULL, (" + selectGroupOCountSQL + ") " + direction + return " ORDER BY (" + selectGroupOCountSQL + ") " + direction } diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 0a516434de..2d4d6c71fe 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -682,7 +682,7 @@ func (qb *PerformerStore) QueryCount(ctx context.Context, performerFilter *model func (qb *PerformerStore) sortByOCounter(direction string) string { // need to sum the o_counter from scenes and images - return " ORDER BY (" + selectPerformerOCountSQL + ") IS NULL, (" + selectPerformerOCountSQL + ") " + direction + return " ORDER BY (" + selectPerformerOCountSQL + ") " + direction } func (qb *PerformerStore) sortByPlayCount(direction string) string { @@ -711,7 +711,7 @@ var selectPerformerLastOAtSQL = utils.StrFormat( func (qb *PerformerStore) sortByLastOAt(direction string) string { // need to get the o_dates from scenes - return " ORDER BY (" + selectPerformerLastOAtSQL + ") IS NULL, (" + selectPerformerLastOAtSQL + ") " + direction + return " ORDER BY (" + selectPerformerLastOAtSQL + ") " + direction } // used for sorting on performer latest scene @@ -733,7 +733,7 @@ var selectPerformerLatestSceneSQL = utils.StrFormat( func (qb *PerformerStore) sortByLatestScene(direction string) string { // need to get the latest date from scenes - return " ORDER BY (" + selectPerformerLatestSceneSQL + ") IS NULL, (" + selectPerformerLatestSceneSQL + ") " + direction + return " ORDER BY (" + selectPerformerLatestSceneSQL + ") " + direction } // used for sorting on performer last view_date @@ -757,7 +757,7 @@ var selectPerformerLastPlayedAtSQL = utils.StrFormat( func (qb *PerformerStore) sortByLastPlayedAt(direction string) string { // need to get the view_dates from scenes - return " ORDER BY (" + selectPerformerLastPlayedAtSQL + ") IS NULL, (" + selectPerformerLastPlayedAtSQL + ") " + direction + return " ORDER BY (" + selectPerformerLastPlayedAtSQL + ") " + direction } // used for sorting by total scene duration diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 72439dc52b..42e07ec208 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -1264,9 +1264,9 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF case "play_count": query.sortAndPagination += getCountSort(sceneTable, scenesViewDatesTable, sceneIDColumn, direction) case "last_played_at": - query.sortAndPagination += fmt.Sprintf(" ORDER BY (SELECT MAX(view_date) FROM %s AS sort WHERE sort.%s = %s.id) IS NULL, (SELECT MAX(view_date) FROM %s AS sort WHERE sort.%s = %s.id) %s", scenesViewDatesTable, sceneIDColumn, sceneTable, scenesViewDatesTable, sceneIDColumn, sceneTable, getSortDirection(direction)) + query.sortAndPagination += fmt.Sprintf(" ORDER BY (SELECT MAX(view_date) FROM %s AS sort WHERE sort.%s = %s.id) %s", scenesViewDatesTable, sceneIDColumn, sceneTable, getSortDirection(direction)) case "last_o_at": - query.sortAndPagination += fmt.Sprintf(" ORDER BY (SELECT MAX(o_date) FROM %s AS sort WHERE sort.%s = %s.id) IS NULL, (SELECT MAX(o_date) FROM %s AS sort WHERE sort.%s = %s.id) %s", scenesODatesTable, sceneIDColumn, sceneTable, scenesODatesTable, sceneIDColumn, sceneTable, getSortDirection(direction)) + query.sortAndPagination += fmt.Sprintf(" ORDER BY (SELECT MAX(o_date) FROM %s AS sort WHERE sort.%s = %s.id) %s", scenesODatesTable, sceneIDColumn, sceneTable, getSortDirection(direction)) case "o_counter": query.sortAndPagination += getCountSort(sceneTable, scenesODatesTable, sceneIDColumn, direction) case "performer_age": diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 30215385ea..760bf2f56a 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -655,7 +655,7 @@ var selectStudioLatestSceneSQL = utils.StrFormat( func (qb *StudioStore) sortByLatestScene(direction string) string { // need to get the latest date from scenes - return " ORDER BY (" + selectStudioLatestSceneSQL + ") IS NULL, (" + selectStudioLatestSceneSQL + ") " + direction + return " ORDER BY (" + selectStudioLatestSceneSQL + ") " + direction } var studioSortOptions = sortOptions{ From 4eab9d5edd905643417da60207fca7c1bfc8f2bb Mon Sep 17 00:00:00 2001 From: slick-daddy Date: Tue, 7 Jul 2026 21:57:02 +0300 Subject: [PATCH 11/11] Address PR review: fix birthdate sort direction, add sub-studios i18n key --- pkg/sqlite/performer.go | 10 +--------- .../src/components/Performers/PerformerList.tsx | 16 ++++++++++++++-- .../components/Studios/StudioDetails/Studio.tsx | 4 ++-- .../src/components/Studios/StudioListTable.tsx | 2 +- ui/v2.5/src/locales/en-GB.json | 1 + 5 files changed, 19 insertions(+), 14 deletions(-) diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 2d4d6c71fe..2b96f456b0 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -873,16 +873,8 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (s case "latest_scene": sortQuery += qb.sortByLatestScene(direction) case "birthdate": - // birthdate is inversely related to age (older = lower date). - // flip direction so DESC means "oldest first". colName := getColumn(performerTable, "birthdate") - dir := getSortDirection(direction) - if dir == "ASC" { - dir = "DESC" - } else { - dir = "ASC" - } - sortQuery += " ORDER BY " + colName + " IS NULL, " + colName + " " + dir + sortQuery += " ORDER BY " + colName + " IS NULL, " + colName + " " + getSortDirection(direction) default: sortQuery += getSort(sort, direction, "performers") } diff --git a/ui/v2.5/src/components/Performers/PerformerList.tsx b/ui/v2.5/src/components/Performers/PerformerList.tsx index 0d7042781e..bd5a205c21 100644 --- a/ui/v2.5/src/components/Performers/PerformerList.tsx +++ b/ui/v2.5/src/components/Performers/PerformerList.tsx @@ -233,6 +233,13 @@ const PerformerList: React.FC<{ filter.sortBy ); + const displaySortDirection = + activeSortColumn === "age" + ? filter.sortDirection === GQL.SortDirectionEnum.Asc + ? GQL.SortDirectionEnum.Desc + : GQL.SortDirectionEnum.Asc + : filter.sortDirection; + if (performers.length === 0 && filter.displayMode !== DisplayMode.Tagger) { return null; } @@ -256,7 +263,7 @@ const PerformerList: React.FC<{ onSelectChange={onSelectChange} onSort={onSort} sortBy={activeSortColumn} - sortDirection={filter.sortDirection} + sortDirection={displaySortDirection} /> ); } @@ -438,7 +445,12 @@ export const FilteredPerformerList = PatchComponent( setFilter(filter.toggleSortDirection()); } else { const newFilter = filter.setSortBy(backendField); - newFilter.sortDirection = GQL.SortDirectionEnum.Asc; + // birthdate is inversely related to age; default Desc so + // youngest-first (Asc in the age column display) + newFilter.sortDirection = + value === "age" + ? GQL.SortDirectionEnum.Desc + : GQL.SortDirectionEnum.Asc; setFilter(newFilter); } }, diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 4fc7072315..4ddc32831a 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -511,9 +511,9 @@ const StudioPage: React.FC = PatchComponent( clickToRate withoutContext /> - {showAllCounts && !!studio.o_counter_all ? ( + {showAllCounts && studio.o_counter_all ? ( - ) : !showAllCounts && !!studio.o_counter ? ( + ) : !showAllCounts && studio.o_counter ? ( ) : null}
diff --git a/ui/v2.5/src/components/Studios/StudioListTable.tsx b/ui/v2.5/src/components/Studios/StudioListTable.tsx index bae4475a9b..9005f736b0 100644 --- a/ui/v2.5/src/components/Studios/StudioListTable.tsx +++ b/ui/v2.5/src/components/Studios/StudioListTable.tsx @@ -161,7 +161,7 @@ export const StudioListTable: React.FC = ({ {studio.child_studios.length}{" "} {intl.formatMessage( - { id: "studios" }, + { id: "sub_studios" }, { count: studio.child_studios.length } )} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 82b048b8d4..d99bbf0c44 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1632,6 +1632,7 @@ }, "studio_tags": "Studio Tags", "studios": "Studios", + "sub_studios": "{count, plural, one {Sub-Studio} other {Sub-Studios}}", "sub_folder_depth": "Sub folder depth (empty for all)", "sub_folders": "Sub folders", "sub_group": "Sub-Group",