Revamp gallery list with sidebar (#6157)

* Make list operation utility component
* Add defaults for sidebar filters
* Refactor gallery list for sidebar
* Fix gallery styling
* Fix sidebar state issues
* Auto-populate query string into name on create
* Remove new gallery button from navbar
* Make components patchable
This commit is contained in:
WithoutPants 2026-02-04 16:45:59 +11:00 committed by GitHub
parent 88eb46380c
commit b5de30a295
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1084 additions and 745 deletions

View file

@ -4,7 +4,7 @@ import { Helmet } from "react-helmet";
import { useTitleProps } from "src/hooks/title";
import Gallery from "./GalleryDetails/Gallery";
import GalleryCreate from "./GalleryDetails/GalleryCreate";
import { GalleryList } from "./GalleryList";
import { FilteredGalleryList } from "./GalleryList";
import { View } from "../List/views";
import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { ErrorMessage } from "../Shared/ErrorMessage";
@ -40,7 +40,7 @@ const GalleryImage: React.FC<RouteComponentProps<IGalleryImageParams>> = ({
};
const Galleries: React.FC = () => {
return <GalleryList view={View.Galleries} />;
return <FilteredGalleryList view={View.Galleries} />;
};
const GalleryRoutes: React.FC = () => {

View file

@ -1,10 +1,10 @@
import React, { useState } from "react";
import { useIntl } from "react-intl";
import React, { useCallback, useEffect } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import cloneDeep from "lodash-es/cloneDeep";
import { useHistory } from "react-router-dom";
import { useHistory, useLocation } from "react-router-dom";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import { ItemList, ItemListContext, showWhenSelected } 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 { queryFindGalleries, useFindGalleries } from "src/core/StashService";
@ -16,17 +16,167 @@ import { GenerateDialog } from "../Dialogs/GenerateDialog";
import { GalleryListTable } from "./GalleryListTable";
import { GalleryCardGrid } from "./GalleryCardGrid";
import { View } from "../List/views";
import { PatchComponent } from "src/patch";
import { IItemListOperation } from "../List/FilteredListToolbar";
import { useModal } from "src/hooks/modal";
import useFocus from "src/utils/focus";
import {
Sidebar,
SidebarPane,
SidebarPaneContent,
SidebarStateContext,
useSidebarState,
} from "../Shared/Sidebar";
import { useCloseEditDelete, useFilterOperations } from "../List/util";
import {
FilteredSidebarHeader,
useFilteredSidebarKeybinds,
} from "../List/Filters/FilterSidebar";
import cx from "classnames";
import { LoadedContent } from "../List/PagedList";
import { Pagination, PaginationIndex } from "../List/Pagination";
import { PatchComponent, PatchContainerComponent } from "src/patch";
import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter";
import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter";
import { SidebarTagsFilter } from "../List/Filters/TagsFilter";
import { SidebarRatingFilter } from "../List/Filters/RatingFilter";
import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter";
import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized";
import { Button } from "react-bootstrap";
import { ListOperations } from "../List/ListOperationButtons";
import {
FilteredListToolbar,
IItemListOperation,
} from "../List/FilteredListToolbar";
import { FilterTags } from "../List/FilterTags";
function getItems(result: GQL.FindGalleriesQueryResult) {
return result?.data?.findGalleries?.galleries ?? [];
}
const GalleryList: React.FC<{
galleries: GQL.SlimGalleryDataFragment[];
filter: ListFilterModel;
selectedIds: Set<string>;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}> = PatchComponent(
"GalleryList",
({ galleries, filter, selectedIds, onSelectChange }) => {
if (galleries.length === 0) {
return null;
}
function getCount(result: GQL.FindGalleriesQueryResult) {
return result?.data?.findGalleries?.count ?? 0;
}
if (filter.displayMode === DisplayMode.Grid) {
return (
<GalleryCardGrid
galleries={galleries}
selectedIds={selectedIds}
zoomIndex={filter.zoomIndex}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.List) {
return (
<GalleryListTable
galleries={galleries}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.Wall) {
return (
<div className={`GalleryWall zoom-${filter.zoomIndex}`}>
{galleries.map((gallery) => (
<GalleryWallCard
key={gallery.id}
gallery={gallery}
selected={selectedIds.has(gallery.id)}
onSelectedChanged={(selected, shiftKey) =>
onSelectChange(gallery.id, selected, shiftKey)
}
selecting={selectedIds.size > 0}
/>
))}
</div>
);
}
return null;
}
);
const GalleryFilterSidebarSections = PatchContainerComponent(
"FilteredGalleryList.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<typeof useFocus>;
}> = ({
filter,
setFilter,
filterHook,
view,
showEditFilter,
sidebarOpen,
onClose,
count,
focus,
}) => {
const showResultsId =
count !== undefined ? "actions.show_count_results" : "actions.show_results";
const hideStudios = view === View.StudioScenes;
return (
<>
<FilteredSidebarHeader
sidebarOpen={sidebarOpen}
showEditFilter={showEditFilter}
filter={filter}
setFilter={setFilter}
view={view}
focus={focus}
/>
<GalleryFilterSidebarSections>
{!hideStudios && (
<SidebarStudiosFilter
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
/>
)}
<SidebarPerformersFilter
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
/>
<SidebarTagsFilter
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
/>
<SidebarRatingFilter filter={filter} setFilter={setFilter} />
<SidebarBooleanFilter
title={<FormattedMessage id="organized" />}
data-type={OrganizedCriterionOption.type}
option={OrganizedCriterionOption}
filter={filter}
setFilter={setFilter}
/>
</GalleryFilterSidebarSections>
<div className="sidebar-footer">
<Button className="sidebar-close-button" onClick={onClose}>
<FormattedMessage id={showResultsId} values={{ count }} />
</Button>
</div>
</>
);
};
interface IGalleryList {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
@ -35,208 +185,324 @@ interface IGalleryList {
extraOperations?: IItemListOperation<GQL.FindGalleriesQueryResult>[];
}
export const GalleryList: React.FC<IGalleryList> = PatchComponent(
"GalleryList",
({ filterHook, view, alterQuery, extraOperations = [] }) => {
function useViewRandom(filter: ListFilterModel, count: number) {
const history = useHistory();
const viewRandom = useCallback(async () => {
// query for a random scene
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 queryFindGalleries(filterCopy);
if (singleResult.data.findGalleries.galleries.length === 1) {
const { id } = singleResult.data.findGalleries.galleries[0];
// navigate to the image player page
history.push(`/galleries/${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 FilteredGalleryList = PatchComponent(
"FilteredGalleryList",
(props: IGalleryList) => {
const intl = useIntl();
const history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
const { modal, showModal, closeModal } = useModal();
const location = useLocation();
const filterMode = GQL.FilterMode.Galleries;
const searchFocus = useFocus();
const { filterHook, view, alterQuery } = props;
// States
const {
showSidebar,
setShowSidebar,
sectionOpen,
setSectionOpen,
loading: sidebarStateLoading,
} = useSidebarState(view);
const { filterState, queryResult, modalState, listSelect, showEditFilter } =
useFilteredItemList({
filterStateProps: {
filterMode: GQL.FilterMode.Galleries,
view,
useURL: alterQuery,
},
queryResultProps: {
useResult: useFindGalleries,
getCount: (r) => r.data?.findGalleries.count ?? 0,
getItems: (r) => r.data?.findGalleries.galleries ?? [],
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("e");
Mousetrap.unbind("d d");
};
});
const onCloseEditDelete = useCloseEditDelete({
closeModal,
onSelectNone,
result,
});
function onCreateNew() {
let queryParam = new URLSearchParams(location.search).get("q");
let newPath = "/galleries/new";
if (queryParam) {
newPath += "?q=" + encodeURIComponent(queryParam);
}
history.push(newPath);
}
const viewRandom = useViewRandom(filter, totalCount);
function onExport(all: boolean) {
showModal(
<ExportDialog
exportInput={{
galleries: {
ids: Array.from(selectedIds.values()),
all: all,
},
}}
onClose={() => closeModal()}
/>
);
}
function onEdit() {
showModal(
<EditGalleriesDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}
function onDelete() {
showModal(
<DeleteGalleriesDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}
function onGenerate() {
showModal(
<GenerateDialog
type="gallery"
selectedIds={Array.from(selectedIds.values())}
onClose={() => closeModal()}
/>
);
}
const otherOperations = [
...extraOperations,
{
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.generate" })}…`,
onClick: (
_result: GQL.FindGalleriesQueryResult,
_filter: ListFilterModel,
selectedIds: Set<string>
) => {
showModal(
<GenerateDialog
type="gallery"
selectedIds={Array.from(selectedIds.values())}
onClose={() => closeModal()}
/>
);
return Promise.resolve();
},
isDisplayed: showWhenSelected,
onClick: onGenerate,
isDisplayed: () => hasSelection,
},
{
text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport,
isDisplayed: showWhenSelected,
onClick: () => onExport(false),
isDisplayed: () => hasSelection,
},
{
text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll,
onClick: () => onExport(true),
},
];
function addKeybinds(
result: GQL.FindGalleriesQueryResult,
filter: ListFilterModel
) {
Mousetrap.bind("p r", () => {
viewRandom(result, filter);
});
// render
if (sidebarStateLoading) return null;
return () => {
Mousetrap.unbind("p r");
};
}
async function viewRandom(
result: GQL.FindGalleriesQueryResult,
filter: ListFilterModel
) {
// query for a random image
if (result.data?.findGalleries) {
const { count } = result.data.findGalleries;
const index = Math.floor(Math.random() * count);
const filterCopy = cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindGalleries(filterCopy);
if (singleResult.data.findGalleries.galleries.length === 1) {
const { id } = singleResult.data.findGalleries.galleries[0];
// navigate to the image player page
history.push(`/galleries/${id}`);
}
}
}
async function onExport() {
setIsExportAll(false);
setIsExportDialogOpen(true);
}
async function onExportAll() {
setIsExportAll(true);
setIsExportDialogOpen(true);
}
function renderContent(
result: GQL.FindGalleriesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) {
function maybeRenderGalleryExportDialog() {
if (isExportDialogOpen) {
return (
<ExportDialog
exportInput={{
galleries: {
ids: Array.from(selectedIds.values()),
all: isExportAll,
},
}}
onClose={() => setIsExportDialogOpen(false)}
/>
);
}
}
function renderGalleries() {
if (!result.data?.findGalleries) return;
if (filter.displayMode === DisplayMode.Grid) {
return (
<GalleryCardGrid
galleries={result.data.findGalleries.galleries}
selectedIds={selectedIds}
zoomIndex={filter.zoomIndex}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.List) {
return (
<GalleryListTable
galleries={result.data.findGalleries.galleries}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.Wall) {
return (
<div className="row">
<div className={`GalleryWall zoom-${filter.zoomIndex}`}>
{result.data.findGalleries.galleries.map((gallery) => (
<GalleryWallCard
key={gallery.id}
gallery={gallery}
selected={selectedIds.has(gallery.id)}
onSelectedChanged={(selected, shiftKey) =>
onSelectChange(gallery.id, selected, shiftKey)
}
selecting={selectedIds.size > 0}
/>
))}
</div>
</div>
);
}
}
return (
<>
{maybeRenderGalleryExportDialog()}
{modal}
{renderGalleries()}
</>
);
}
function renderEditDialog(
selectedImages: GQL.SlimGalleryDataFragment[],
onClose: (applied: boolean) => void
) {
return (
<EditGalleriesDialog selected={selectedImages} onClose={onClose} />
);
}
function renderDeleteDialog(
selectedImages: GQL.SlimGalleryDataFragment[],
onClose: (confirmed: boolean) => void
) {
return (
<DeleteGalleriesDialog selected={selectedImages} onClose={onClose} />
);
}
const operations = (
<ListOperations
items={items.length}
hasSelection={hasSelection}
operations={otherOperations}
onEdit={onEdit}
onDelete={onDelete}
onCreateNew={onCreateNew}
entityType={intl.formatMessage({ id: "gallery" })}
operationsMenuClassName="gallery-list-operations-dropdown"
/>
);
return (
<ItemListContext
filterMode={filterMode}
useResult={useFindGalleries}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
selectable
<div
className={cx("item-list-container gallery-list", {
"hide-sidebar": !showSidebar,
})}
>
<ItemList
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
</ItemListContext>
{modal}
<SidebarStateContext.Provider value={{ sectionOpen, setSectionOpen }}>
<SidebarPane hideSidebar={!showSidebar}>
<Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>
<SidebarContent
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
showEditFilter={showEditFilter}
view={view}
sidebarOpen={showSidebar}
onClose={() => setShowSidebar(false)}
count={cachedResult.loading ? undefined : totalCount}
focus={searchFocus}
/>
</Sidebar>
<SidebarPaneContent
onSidebarToggle={() => setShowSidebar(!showSidebar)}
>
<FilteredListToolbar
filter={filter}
listSelect={listSelect}
setFilter={setFilter}
showEditFilter={showEditFilter}
onDelete={onDelete}
onEdit={onEdit}
operationComponent={operations}
view={view}
zoomable
/>
<FilterTags
criteria={filter.criteria}
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
onRemoveCriterion={removeCriterion}
onRemoveAll={clearAllCriteria}
/>
<div className="pagination-index-container">
<Pagination
currentPage={filter.currentPage}
itemsPerPage={filter.itemsPerPage}
totalItems={totalCount}
onChangePage={(page) => setFilter(filter.changePage(page))}
/>
<PaginationIndex
loading={cachedResult.loading}
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
/>
</div>
<LoadedContent loading={result.loading} error={result.error}>
<GalleryList
filter={effectiveFilter}
galleries={items}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
</LoadedContent>
{totalCount > filter.itemsPerPage && (
<div className="pagination-footer-container">
<div className="pagination-footer">
<Pagination
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
onChangePage={setPage}
pagePopupPlacement="top"
/>
</div>
</div>
)}
</SidebarPaneContent>
</SidebarPane>
</SidebarStateContext.Provider>
</div>
);
}
);

View file

@ -229,7 +229,6 @@ div.GalleryWall {
display: flex;
flex-wrap: wrap;
margin: 0 auto;
width: 96vw;
/* Prevents last row from consuming all space and stretching images to oblivion */
&::after {

View file

@ -18,6 +18,7 @@ import { Option } from "./SidebarListFilter";
import {
CriterionModifier,
FilterMode,
GalleryFilterType,
InputMaybe,
IntCriterionInput,
SceneFilterType,
@ -515,12 +516,14 @@ export function makeQueryVariables(query: string, extraProps: {}) {
interface IFilterType {
scenes_filter?: InputMaybe<SceneFilterType>;
scene_count?: InputMaybe<IntCriterionInput>;
galleries_filter?: InputMaybe<GalleryFilterType>;
gallery_count?: InputMaybe<IntCriterionInput>;
}
export function setObjectFilter(
out: IFilterType,
mode: FilterMode,
relatedFilterOutput: SceneFilterType
relatedFilterOutput: SceneFilterType | GalleryFilterType
) {
const empty = Object.keys(relatedFilterOutput).length === 0;
@ -535,5 +538,17 @@ export function setObjectFilter(
}
out.scenes_filter = relatedFilterOutput;
break;
case FilterMode.Galleries:
// if empty, only get objects with galleries
if (empty) {
out.gallery_count = {
modifier: CriterionModifier.GreaterThan,
value: 0,
};
}
out.galleries_filter = relatedFilterOutput;
break;
default:
throw new Error("Invalid filter mode");
}
}

View file

@ -1,5 +1,8 @@
import React, { ReactNode, useMemo } from "react";
import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
import {
PerformersCriterion,
PerformersCriterionOption,
} from "src/models/list-filter/criteria/performers";
import {
CriterionModifier,
FindPerformersForSelectQueryVariables,
@ -18,6 +21,7 @@ import {
useLabeledIdFilterState,
} from "./LabeledIdFilter";
import { SidebarListFilter } from "./SidebarListFilter";
import { FormattedMessage } from "react-intl";
interface IPerformersFilter {
criterion: PerformersCriterion;
@ -106,12 +110,19 @@ const PerformersFilter: React.FC<IPerformersFilter> = ({
export const SidebarPerformersFilter: React.FC<{
title?: ReactNode;
option: CriterionOption;
option?: CriterionOption;
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
filterHook?: (f: ListFilterModel) => ListFilterModel;
sectionID?: string;
}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => {
}> = ({
title = <FormattedMessage id="performers" />,
option = PerformersCriterionOption,
filter,
setFilter,
filterHook,
sectionID = "performers",
}) => {
const state = useLabeledIdFilterState({
filter,
setFilter,
@ -120,7 +131,14 @@ export const SidebarPerformersFilter: React.FC<{
useQuery: usePerformerQueryFilter,
});
return <SidebarListFilter {...state} title={title} sectionID={sectionID} />;
return (
<SidebarListFilter
{...state}
data-type={option.type}
title={title}
sectionID={sectionID}
/>
);
};
export default PerformersFilter;

View file

@ -13,7 +13,10 @@ import {
defaultRatingSystemOptions,
} from "src/utils/rating";
import { useConfigurationContext } from "src/hooks/Config";
import { RatingCriterion } from "src/models/list-filter/criteria/rating";
import {
RatingCriterion,
RatingCriterionOption,
} from "src/models/list-filter/criteria/rating";
import { ListFilterModel } from "src/models/list-filter/filter";
import { Option, SidebarListFilter } from "./SidebarListFilter";
@ -74,7 +77,7 @@ export const RatingFilter: React.FC<IRatingFilterProps> = ({
interface ISidebarFilter {
title?: React.ReactNode;
option: CriterionOption;
option?: CriterionOption;
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
sectionID?: string;
@ -84,11 +87,11 @@ const any = "any";
const none = "none";
export const SidebarRatingFilter: React.FC<ISidebarFilter> = ({
title,
option,
title = <FormattedMessage id="rating" />,
option = RatingCriterionOption,
filter,
setFilter,
sectionID,
sectionID = "rating",
}) => {
const intl = useIntl();
@ -193,6 +196,7 @@ export const SidebarRatingFilter: React.FC<ISidebarFilter> = ({
return (
<>
<SidebarListFilter
data-type={option.type}
title={title}
candidates={options}
onSelect={onSelect}

View file

@ -7,10 +7,12 @@ import { Option, SidebarListFilter } from "./SidebarListFilter";
import TextUtils from "src/utils/text";
import { DoubleRangeInput } from "src/components/Shared/DoubleRangeInput";
import { useDebounce } from "src/hooks/debounce";
import { FormattedMessage } from "react-intl";
import { DurationCriterionOption } from "src/models/list-filter/scenes";
interface ISidebarFilter {
title?: React.ReactNode;
option: CriterionOption;
option?: CriterionOption;
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
sectionID?: string;
@ -55,11 +57,11 @@ function snapToStep(value: number): number {
}
export const SidebarDurationFilter: React.FC<ISidebarFilter> = ({
title,
option,
title = <FormattedMessage id="duration" />,
option = DurationCriterionOption,
filter,
setFilter,
sectionID,
sectionID = "duration",
}) => {
const criteria = filter.criteriaFor(option.type) as DurationCriterion[];
const criterion = criteria.length > 0 ? criteria[0] : null;

View file

@ -5,7 +5,10 @@ import {
useFindStudiosForSelectQuery,
} from "src/core/generated-graphql";
import { HierarchicalObjectsFilter } from "./SelectableFilter";
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
import {
StudiosCriterion,
StudiosCriterionOption,
} from "src/models/list-filter/criteria/studios";
import { sortByRelevance } from "src/utils/query";
import { CriterionOption } from "src/models/list-filter/criteria/criterion";
import { ListFilterModel } from "src/models/list-filter/filter";
@ -16,6 +19,7 @@ import {
useLabeledIdFilterState,
} from "./LabeledIdFilter";
import { SidebarListFilter } from "./SidebarListFilter";
import { FormattedMessage } from "react-intl";
interface IStudiosFilter {
criterion: StudiosCriterion;
@ -94,12 +98,19 @@ const StudiosFilter: React.FC<IStudiosFilter> = ({
export const SidebarStudiosFilter: React.FC<{
title?: ReactNode;
option: CriterionOption;
option?: CriterionOption;
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
filterHook?: (f: ListFilterModel) => ListFilterModel;
sectionID?: string;
}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => {
}> = ({
title = <FormattedMessage id="studios" />,
option = StudiosCriterionOption,
filter,
setFilter,
filterHook,
sectionID = "studios",
}) => {
const state = useLabeledIdFilterState({
filter,
setFilter,
@ -111,7 +122,14 @@ export const SidebarStudiosFilter: React.FC<{
includeSubMessageID: "subsidiary_studios",
});
return <SidebarListFilter {...state} title={title} sectionID={sectionID} />;
return (
<SidebarListFilter
{...state}
data-type={option.type}
title={title}
sectionID={sectionID}
/>
);
};
export default StudiosFilter;

View file

@ -16,7 +16,11 @@ import {
useLabeledIdFilterState,
} from "./LabeledIdFilter";
import { SidebarListFilter } from "./SidebarListFilter";
import { TagsCriterion } from "src/models/list-filter/criteria/tags";
import {
TagsCriterion,
TagsCriterionOption,
} from "src/models/list-filter/criteria/tags";
import { FormattedMessage } from "react-intl";
interface ITagsFilter {
criterion: TagsCriterion;
@ -99,12 +103,19 @@ const TagsFilter: React.FC<ITagsFilter> = ({ criterion, setCriterion }) => {
export const SidebarTagsFilter: React.FC<{
title?: ReactNode;
option: CriterionOption;
option?: CriterionOption;
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
filterHook?: (f: ListFilterModel) => ListFilterModel;
sectionID?: string;
}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => {
}> = ({
title = <FormattedMessage id="tags" />,
option = TagsCriterionOption,
filter,
setFilter,
filterHook,
sectionID = "tags",
}) => {
const state = useLabeledIdFilterState({
filter,
setFilter,
@ -115,7 +126,14 @@ export const SidebarTagsFilter: React.FC<{
includeSubMessageID: "sub_tags",
});
return <SidebarListFilter {...state} title={title} sectionID={sectionID} />;
return (
<SidebarListFilter
{...state}
data-type={option.type}
title={title}
sectionID={sectionID}
/>
);
};
export default TagsFilter;

View file

@ -6,7 +6,10 @@ import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { Icon } from "../Shared/Icon";
import {
faEllipsisH,
faPencil,
faPencilAlt,
faPlay,
faPlus,
faTrash,
} from "@fortawesome/free-solid-svg-icons";
import cx from "classnames";
@ -264,3 +267,108 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
</>
);
};
interface IListOperations {
text: string;
onClick: () => void;
isDisplayed?: () => boolean;
className?: string;
}
export const ListOperations: React.FC<{
items: number;
hasSelection?: boolean;
operations?: IListOperations[];
onEdit?: () => void;
onDelete?: () => void;
onPlay?: () => void;
onCreateNew?: () => void;
entityType?: string;
operationsClassName?: string;
operationsMenuClassName?: string;
}> = ({
items,
hasSelection = false,
operations = [],
onEdit,
onDelete,
onPlay,
onCreateNew,
entityType,
operationsClassName = "list-operations",
operationsMenuClassName,
}) => {
const intl = useIntl();
return (
<div className="list-operations">
<ButtonGroup>
{!!items && onPlay && (
<Button
className="play-button"
variant="secondary"
onClick={() => onPlay()}
title={intl.formatMessage({ id: "actions.play" })}
>
<Icon icon={faPlay} />
</Button>
)}
{!hasSelection && onCreateNew && (
<Button
className="create-new-button"
variant="secondary"
onClick={() => onCreateNew()}
title={intl.formatMessage(
{ id: "actions.create_entity" },
{ entityType }
)}
>
<Icon icon={faPlus} />
</Button>
)}
{hasSelection && (onEdit || onDelete) && (
<>
{onEdit && (
<Button variant="secondary" onClick={() => onEdit()}>
<Icon icon={faPencil} />
</Button>
)}
{onDelete && (
<Button
variant="danger"
className="btn-danger-minimal"
onClick={() => onDelete()}
>
<Icon icon={faTrash} />
</Button>
)}
</>
)}
{operations.length > 0 && (
<OperationDropdown
className={operationsClassName}
menuClassName={operationsMenuClassName}
menuPortalTarget={document.body}
>
{operations.map((o) => {
if (o.isDisplayed && !o.isDisplayed()) {
return null;
}
return (
<OperationDropdownItem
key={o.text}
onClick={o.onClick}
text={o.text}
className={o.className}
/>
);
})}
</OperationDropdown>
)}
</ButtonGroup>
</div>
);
};

View file

@ -1120,7 +1120,8 @@ input[type="range"].zoom-slider {
justify-content: flex-end;
}
.scene-list-toolbar .selected-items-info {
.scene-list-toolbar .selected-items-info,
.gallery-list-toolbar .selected-items-info {
justify-content: flex-start;
}

View file

@ -132,7 +132,6 @@ const allMenuItems: IMenuItem[] = [
href: "/galleries",
icon: faImages,
hotkey: "g l",
userCreatable: true,
},
{
name: "performers",

View file

@ -1,6 +1,6 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { GalleryList } from "src/components/Galleries/GalleryList";
import { FilteredGalleryList } from "src/components/Galleries/GalleryList";
import { usePerformerFilterHook } from "src/core/performers";
import { View } from "src/components/List/views";
import { PatchComponent } from "src/patch";
@ -14,7 +14,7 @@ export const PerformerGalleriesPanel: React.FC<IPerformerDetailsProps> =
PatchComponent("PerformerGalleriesPanel", ({ active, performer }) => {
const filterHook = usePerformerFilterHook(performer);
return (
<GalleryList
<FilteredGalleryList
filterHook={filterHook}
alterQuery={active}
view={View.PerformerGalleries}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { GalleryList } from "src/components/Galleries/GalleryList";
import { FilteredGalleryList } from "src/components/Galleries/GalleryList";
import { useStudioFilterHook } from "src/core/studios";
import { View } from "src/components/List/views";
@ -17,7 +17,7 @@ export const StudioGalleriesPanel: React.FC<IStudioGalleriesPanel> = ({
}) => {
const filterHook = useStudioFilterHook(studio, showChildStudioContent);
return (
<GalleryList
<FilteredGalleryList
filterHook={filterHook}
alterQuery={active}
view={View.StudioGalleries}

View file

@ -1,7 +1,7 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { useTagFilterHook } from "src/core/tags";
import { GalleryList } from "src/components/Galleries/GalleryList";
import { FilteredGalleryList } from "src/components/Galleries/GalleryList";
import { View } from "src/components/List/views";
interface ITagGalleriesPanel {
@ -17,7 +17,7 @@ export const TagGalleriesPanel: React.FC<ITagGalleriesPanel> = ({
}) => {
const filterHook = useTagFilterHook(tag, showSubTagContent);
return (
<GalleryList
<FilteredGalleryList
filterHook={filterHook}
alterQuery={active}
view={View.TagGalleries}

View file

@ -229,6 +229,8 @@ Returns `void`.
- `DetailImage`
- `ExternalLinkButtons`
- `ExternalLinksButton`
- `FilteredGalleryList`
- `FilteredSceneList`
- `FolderSelect`
- `FrontPage`
- `GalleryCard`
@ -238,6 +240,7 @@ Returns `void`.
- `GalleryCard.Popovers`
- `GalleryCardGrid`
- `GalleryIDSelect`
- `GalleryList`
- `GalleryRecommendationRow`
- `GallerySelect`
- `GallerySelect.sort`
@ -308,6 +311,7 @@ Returns `void`.
- `SceneMarkerCard.Popovers`
- `SceneMarkerCardsGrid`
- `SceneMarkerRecommendationRow`
- `SceneList`
- `ScenePage`
- `ScenePage.TabContent`
- `ScenePage.Tabs`

View file

@ -526,8 +526,6 @@ textarea.text-input {
}
.zoom-1 {
width: 320px;
.gallery-card-image,
.tag-card-image {
height: 240px;

View file

@ -666,6 +666,8 @@ declare namespace PluginApi {
DetailImage: React.FC<any>;
ExternalLinkButtons: React.FC<any>;
ExternalLinksButton: React.FC<any>;
FilteredGalleryList: React.FC<any>;
FilteredSceneList: React.FC<any>;
FolderSelect: React.FC<any>;
FrontPage: React.FC<any>;
GalleryCard: React.FC<any>;