Add folder criteria to scenes, images and galleries and sidebars (#6636)

* Add useDebouncedState hook
* Add basename to folder sort whitelist
* Add parent_folder criterion to gallery
* Add selection on enter if single result
This commit is contained in:
WithoutPants 2026-03-05 08:02:13 +11:00 committed by GitHub
parent 697c66ae62
commit 717f968a2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1025 additions and 70 deletions

View file

@ -603,6 +603,8 @@ input GalleryFilterType {
files_filter: FileFilterType
"Filter by related folders that meet this criteria"
folders_filter: FolderFilterType
"Filter by parent folder of the zip or folder the gallery is in"
parent_folder: HierarchicalMultiCriterionInput
custom_fields: [CustomFieldCriterionInput!]
}

View file

@ -11,6 +11,8 @@ type GalleryFilterType struct {
Checksum *StringCriterionInput `json:"checksum"`
// Filter by path
Path *StringCriterionInput `json:"path"`
// Filter by parent folder
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"`
// Filter by zip file count
FileCount *IntCriterionInput `json:"file_count"`
// Filter to only include galleries missing this property

View file

@ -614,6 +614,7 @@ var folderSortOptions = sortOptions{
"created_at",
"id",
"path",
"basename",
"random",
"updated_at",
}

View file

@ -84,6 +84,7 @@ func (qb *galleryFilterHandler) criterionHandler() criterionHandler {
}),
qb.pathCriterionHandler(filter.Path),
qb.parentFolderCriterionHandler(filter.ParentFolder),
qb.fileCountCriterionHandler(filter.FileCount),
intCriterionHandler(filter.Rating100, "galleries.rating", nil),
qb.urlsCriterionHandler(filter.URL),
@ -278,6 +279,65 @@ func (qb *galleryFilterHandler) pathCriterionHandler(c *models.StringCriterionIn
}
}
func (qb *galleryFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if folder == nil {
return
}
galleryRepository.addFoldersTable(f)
f.addLeftJoin(folderTable, "gallery_folder", "galleries.folder_id = gallery_folder.id")
criterion := *folder
switch criterion.Modifier {
case models.CriterionModifierEquals:
criterion.Modifier = models.CriterionModifierIncludes
case models.CriterionModifierNotEquals:
criterion.Modifier = models.CriterionModifierExcludes
}
// only allow includes or excludes filters
if criterion.Modifier != models.CriterionModifierIncludes && criterion.Modifier != models.CriterionModifierExcludes {
f.setError(fmt.Errorf("invalid modifier for parent folder criterion: %s", criterion.Modifier))
}
if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 {
return
}
// combine excludes if excludes modifier is selected
if criterion.Modifier == models.CriterionModifierExcludes {
criterion.Modifier = models.CriterionModifierIncludes
criterion.Excludes = append(criterion.Excludes, criterion.Value...)
criterion.Value = nil
}
if len(criterion.Value) > 0 {
valuesClause, err := getHierarchicalValues(ctx, criterion.Value, "folders", "", "parent_folder_id", "parent_folder_id", criterion.Depth)
if err != nil {
f.setError(err)
return
}
// combine clauses with OR to handle zip file or folder
c1 := makeClause(fmt.Sprintf("folders.parent_folder_id IN (SELECT column2 FROM (%s))", valuesClause))
c2 := makeClause(fmt.Sprintf("gallery_folder.parent_folder_id IN (SELECT column2 FROM (%s))", valuesClause))
f.whereClauses = append(f.whereClauses, orClauses(c1, c2))
}
if len(criterion.Excludes) > 0 {
valuesClause, err := getHierarchicalValues(ctx, criterion.Excludes, "folders", "", "parent_folder_id", "parent_folder_id", criterion.Depth)
if err != nil {
f.setError(err)
return
}
f.addWhere(fmt.Sprintf("folders.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR folders.parent_folder_id IS NULL", valuesClause))
f.addWhere(fmt.Sprintf("gallery_folder.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR gallery_folder.parent_folder_id IS NULL", valuesClause))
}
}
}
func (qb *galleryFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc {
h := countCriterionHandlerBuilder{
primaryTable: galleryTable,

View file

@ -1,5 +1,6 @@
fragment FolderData on Folder {
id
basename
path
}
@ -86,3 +87,17 @@ fragment VisualFileData on VisualFile {
}
}
}
fragment SelectFolderData on Folder {
id
path
basename
}
fragment RecursiveFolderData on Folder {
...SelectFolderData
parent_folders {
...SelectFolderData
}
}

View file

@ -0,0 +1,24 @@
query FindRootFoldersForSelect {
findFolders(
filter: { per_page: -1, sort: "path", direction: ASC }
folder_filter: { parent_folder: { modifier: IS_NULL } }
) {
count
folders {
...SelectFolderData
}
}
}
query FindFoldersForQuery(
$filter: FindFilterType
$folder_filter: FolderFilterType
$ids: [ID!]
) {
findFolders(filter: $filter, folder_filter: $folder_filter, ids: $ids) {
count
folders {
...RecursiveFolderData
}
}
}

View file

@ -51,6 +51,8 @@ import {
import { FilterTags } from "../List/FilterTags";
import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter";
import { PerformerAgeCriterionOption } from "src/models/list-filter/galleries";
import { SidebarFolderFilter } from "../List/Filters/FolderFilter";
import { ParentFolderCriterionOption } from "src/models/list-filter/criteria/folder";
const GalleryList: React.FC<{
galleries: GQL.SlimGalleryDataFragment[];
@ -165,6 +167,13 @@ const SidebarContent: React.FC<{
filterHook={filterHook}
/>
<SidebarRatingFilter filter={filter} setFilter={setFilter} />
<SidebarFolderFilter
text={<FormattedMessage id="parent_folder" />}
criterionOption={ParentFolderCriterionOption}
filter={filter}
setFilter={setFilter}
sectionID="parent_folder"
/>
<SidebarBooleanFilter
title={<FormattedMessage id="organized" />}
data-type={OrganizedCriterionOption.type}

View file

@ -66,6 +66,7 @@ import { Button } from "react-bootstrap";
import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized";
import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter";
import { PerformerAgeCriterionOption } from "src/models/list-filter/images";
import { SidebarFolderFilter } from "../List/Filters/FolderFilter";
interface IImageWallProps {
images: GQL.SlimImageDataFragment[];
@ -430,6 +431,12 @@ const SidebarContent: React.FC<{
filterHook={filterHook}
/>
<SidebarRatingFilter filter={filter} setFilter={setFilter} />
<SidebarFolderFilter
text={<FormattedMessage id="folder" />}
filter={filter}
setFilter={setFilter}
sectionID="folder"
/>
<SidebarBooleanFilter
title={<FormattedMessage id="organized" />}
data-type={OrganizedCriterionOption.type}

View file

@ -52,6 +52,11 @@ import { PathCriterion } from "src/models/list-filter/criteria/path";
import { ModifierSelectorButtons } from "./ModifierSelect";
import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields";
import { CustomFieldsFilter } from "./Filters/CustomFieldsFilter";
import { FolderFilter } from "./Filters/FolderFilter";
import {
FolderCriterion,
ParentFolderCriterion,
} from "src/models/list-filter/criteria/folder";
interface IGenericCriterionEditor {
criterion: ModifierCriterion<CriterionValue>;
@ -68,7 +73,9 @@ const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({
if (
criterion instanceof PerformersCriterion ||
criterion instanceof StudiosCriterion ||
criterion instanceof TagsCriterion
criterion instanceof TagsCriterion ||
criterion instanceof FolderCriterion ||
criterion instanceof ParentFolderCriterion
) {
return false;
}
@ -163,6 +170,18 @@ const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({
);
}
if (
criterion instanceof FolderCriterion ||
criterion instanceof ParentFolderCriterion
) {
return (
<FolderFilter
criterion={criterion}
setCriterion={(c) => setCriterion(c)}
/>
);
}
if (criterion instanceof ILabeledIdCriterion) {
return (
<LabeledIdFilter

View file

@ -0,0 +1,612 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
FolderDataFragment,
useFindFoldersForQueryQuery,
useFindRootFoldersForSelectQuery,
} from "src/core/generated-graphql";
import {
ISidebarSectionProps,
SidebarSection,
} from "src/components/Shared/Sidebar";
import {
faChevronDown,
faChevronRight,
faMinus,
faPlus,
} from "@fortawesome/free-solid-svg-icons";
import { ExpandCollapseButton } from "src/components/Shared/CollapseButton";
import cx from "classnames";
import { queryFindSubFolders } from "src/core/StashService";
import { keyboardClickHandler } from "src/utils/keyboard";
import { ListFilterModel } from "src/models/list-filter/filter";
import {
FolderCriterion,
FolderCriterionOption,
} from "src/models/list-filter/criteria/folder";
import { Option, SelectedList } from "./SidebarListFilter";
import {
defineMessages,
FormattedMessage,
MessageDescriptor,
useIntl,
} from "react-intl";
import { Icon } from "src/components/Shared/Icon";
import { Button, Form } from "react-bootstrap";
import { DepthSelector } from "./SelectableFilter";
import ClearableInput from "src/components/Shared/ClearableInput";
import { useDebouncedState } from "src/hooks/debounce";
import { ModifierCriterionOption } from "src/models/list-filter/criteria/criterion";
interface IFolder extends FolderDataFragment {
children?: IFolder[];
expanded: boolean;
}
const FolderRow: React.FC<{
folder: IFolder;
level?: number;
canExclude?: boolean;
toggleExpanded: (folder: IFolder) => void;
onSelect: (folder: IFolder, exclude?: boolean) => void;
}> = ({ folder, level, toggleExpanded, onSelect, canExclude }) => {
return (
<>
<li
className="folder-row unselected-object"
style={{ paddingLeft: (level ?? 0) * 5 }}
>
<a
onClick={() => onSelect(folder)}
onKeyDown={keyboardClickHandler(() => onSelect(folder))}
tabIndex={0}
>
<span>
<span
className={cx({
empty: folder.children && folder.children.length === 0,
})}
>
<ExpandCollapseButton
collapsed={!folder.expanded}
setCollapsed={() => toggleExpanded(folder)}
collapsedIcon={faChevronRight}
notCollapsedIcon={faChevronDown}
/>
</span>
{folder.basename}
</span>
{canExclude && (
<Button
onClick={(e) => {
e.stopPropagation();
onSelect(folder, true);
}}
onKeyDown={(e) => e.stopPropagation()}
className="minimal exclude-button"
>
<span className="exclude-button-text">
<FormattedMessage id="actions.exclude_lowercase" />
</span>
<Icon className="fa-fw exclude-icon" icon={faMinus} />
</Button>
)}
</a>
</li>
{folder.expanded &&
folder.children?.map((child) => (
<FolderRow
key={child.id}
folder={child}
level={(level ?? 0) + 1}
toggleExpanded={toggleExpanded}
onSelect={onSelect}
canExclude={canExclude}
/>
))}
</>
);
};
function toggleExpandedFn(object: IFolder): (f: IFolder) => IFolder {
return (f: IFolder) => {
if (f.id === object.id) {
return { ...f, expanded: !f.expanded };
}
if (f.children) {
return {
...f,
children: f.children.map(toggleExpandedFn(object)),
};
}
return f;
};
}
function replaceFolder(folder: IFolder): (f: IFolder) => IFolder {
return (f: IFolder) => {
if (f.id === folder.id) {
return folder;
}
if (f.children) {
return {
...f,
children: f.children.map(replaceFolder(folder)),
};
}
return f;
};
}
function useFolderMap(query: string, skip?: boolean) {
const { data: rootFoldersResult } = useFindRootFoldersForSelectQuery({
skip,
});
const { data: queryFoldersResult } = useFindFoldersForQueryQuery({
skip: !query,
variables: {
filter: { q: query, per_page: 200 },
},
});
const rootFolders: IFolder[] = useMemo(() => {
const ret = rootFoldersResult?.findFolders.folders ?? [];
return ret.map((f) => ({ ...f, expanded: false, children: undefined }));
}, [rootFoldersResult]);
const queryFolders: IFolder[] = useMemo(() => {
// construct the folder list from the query result
const ret: IFolder[] = [];
(queryFoldersResult?.findFolders.folders ?? []).forEach((folder) => {
if (!folder.parent_folders.length) {
// no parents, just add it if not present
if (!ret.find((f) => f.id === folder.id)) {
ret.push({ ...folder, expanded: true, children: [] });
}
return;
}
// expand the parent folders
let currentParent: IFolder | undefined;
for (let i = folder.parent_folders.length - 1; i >= 0; i--) {
const thisFolder = folder.parent_folders[i];
let existing: IFolder | undefined;
if (i === folder.parent_folders.length - 1) {
// last parent, add the folder as root
existing = ret.find((f) => f.id === thisFolder.id);
if (!existing) {
existing = {
...folder.parent_folders[i],
expanded: true,
children: [],
};
ret.push(existing);
}
currentParent = existing;
continue;
}
// find folder in current parent's children
// currentParent is guaranteed to be defined here
existing = currentParent!.children?.find((f) => f.id === thisFolder.id);
if (!existing) {
// add to current parent's children
existing = {
...thisFolder,
expanded: true,
children: [],
};
currentParent!.children!.push(existing);
}
currentParent = existing;
}
if (!currentParent) {
return;
}
if (!currentParent.children) {
currentParent.children = [];
}
// currentParent is now the immediate parent folder
currentParent!.children!.push({
...folder,
expanded: false,
children: undefined,
});
});
return ret;
}, [queryFoldersResult]);
const [folderMap, setFolderMap] = React.useState<IFolder[]>([]);
useEffect(() => {
if (!query) {
setFolderMap(rootFolders);
} else {
setFolderMap(queryFolders);
}
}, [query, rootFolders, queryFolders]);
async function onToggleExpanded(folder: IFolder) {
setFolderMap(folderMap.map(toggleExpandedFn(folder)));
// query children folders if not already loaded
if (folder.children === undefined) {
const subFolderResult = await queryFindSubFolders(folder.id);
setFolderMap((current) =>
current.map(
replaceFolder({
...folder,
expanded: true,
children: subFolderResult.data.findFolders.folders.map((f) => ({
...f,
expanded: false,
})),
})
)
);
}
}
return { folderMap, onToggleExpanded };
}
function getMatchingFolders(folders: IFolder[], query: string): IFolder[] {
let matches: IFolder[] = [];
const queryLower = query.toLowerCase();
folders.forEach((folder) => {
if (
folder.basename.toLowerCase().includes(queryLower) ||
folder.path.toLowerCase() === queryLower
) {
matches.push(folder);
}
if (folder.children) {
matches = matches.concat(getMatchingFolders(folder.children, query));
}
});
return matches;
}
export const FolderSelector: React.FC<{
onSelect: (folder: IFolder, exclude?: boolean) => void;
canExclude?: boolean;
preListContent?: React.ReactNode;
folderMap: IFolder[];
onToggleExpanded: (folder: IFolder) => void;
}> = ({
onSelect,
preListContent,
canExclude = false,
folderMap,
onToggleExpanded,
}) => {
return (
<ul className="selectable-list">
{preListContent}
{folderMap.map((folder) => (
<FolderRow
key={folder.id}
folder={folder}
onSelect={(f, exclude) => onSelect(f, exclude)}
toggleExpanded={onToggleExpanded}
canExclude={canExclude}
/>
))}
</ul>
);
};
interface IInputFilterProps {
criterion: FolderCriterion;
setCriterion: (c: FolderCriterion) => void;
}
export const FolderFilter: React.FC<IInputFilterProps> = ({
criterion,
setCriterion,
}) => {
const intl = useIntl();
const [query, setQuery] = useState("");
const [displayQuery, onQueryChange] = useDebouncedState(query, setQuery, 250);
const { folderMap, onToggleExpanded } = useFolderMap(query);
const messages = defineMessages({
sub_folder_depth: {
id: "sub_folder_depth",
defaultMessage: "Levels (empty for all)",
},
});
function criterionOptionTypeToIncludeID(): string {
return "include-sub-folders";
}
function criterionOptionTypeToIncludeUIString(): MessageDescriptor {
const optionType = "include_sub_folders";
return {
id: optionType,
};
}
function onDepthChanged(depth: number) {
// this could be ParentFolderCriterion, but the types are the same
const newValue = criterion.clone() as FolderCriterion;
newValue.value.depth = depth;
setCriterion(newValue);
}
function onSelect(folder: IFolder, exclude: boolean = false) {
// toggle selection
const newValue = criterion.clone() as FolderCriterion;
if (!exclude) {
if (newValue.value.items.find((i) => i.id === folder.id)) {
return;
}
newValue.value.items.push({ id: folder.id, label: folder.path });
} else {
if (newValue.value.excluded.find((i) => i.id === folder.id)) {
return;
}
newValue.value.excluded.push({ id: folder.id, label: folder.path });
}
setCriterion(newValue);
}
const onUnselect = useCallback(
(i: Option, excluded?: boolean) => {
const newValue = criterion.clone() as FolderCriterion;
if (!excluded) {
newValue.value.items = newValue.value.items.filter(
(item) => item.id !== i.id
);
} else {
newValue.value.excluded = newValue.value.excluded.filter(
(item) => item.id !== i.id
);
}
setCriterion(newValue);
},
[criterion, setCriterion]
);
function onEnter() {
if (!query) return;
// if there is a single folder that matches the query, select it
const matchingFolders = getMatchingFolders(folderMap, query);
if (matchingFolders.length === 1) {
onSelect(matchingFolders[0]);
}
}
const selectedList = useMemo(() => {
const selected: Option[] =
criterion.value?.items.map((item) => ({
id: item.id,
label: item.label,
})) ?? [];
return <SelectedList items={selected} onUnselect={onUnselect} />;
}, [criterion, onUnselect]);
const excludedList = useMemo(() => {
const selected: Option[] =
criterion.value?.excluded.map((item) => ({
id: item.id,
label: item.label,
})) ?? [];
return (
<SelectedList
excluded
items={selected}
onUnselect={(i) => onUnselect(i, true)}
/>
);
}, [criterion, onUnselect]);
return (
<div className="folder-filter">
<DepthSelector
depth={criterion.value.depth}
onDepthChanged={onDepthChanged}
id={criterionOptionTypeToIncludeID()}
label={intl.formatMessage(criterionOptionTypeToIncludeUIString())}
placeholder={intl.formatMessage(messages.sub_folder_depth)}
/>
<Form.Group>
{selectedList}
{excludedList}
<ClearableInput
value={displayQuery}
setValue={(v) => onQueryChange(v)}
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
onEnter={onEnter}
/>
<FolderSelector
folderMap={folderMap}
onToggleExpanded={onToggleExpanded}
onSelect={onSelect}
canExclude
/>
</Form.Group>
</div>
);
};
export const SidebarFolderFilter: React.FC<
ISidebarSectionProps & {
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
criterionOption?: ModifierCriterionOption;
}
> = (props) => {
const intl = useIntl();
const [skip, setSkip] = useState(true);
const [query, setQuery] = useState("");
const [displayQuery, onQueryChange] = useDebouncedState(query, setQuery, 250);
function onOpen() {
setSkip(false);
props.onOpen?.();
}
const { folderMap, onToggleExpanded } = useFolderMap(query, skip);
const option = props.criterionOption ?? FolderCriterionOption;
const { filter, setFilter } = props;
const criterion = useMemo(() => {
const ret = filter.criteria.find(
(c) => c.criterionOption.type === option.type
);
if (ret) return ret as FolderCriterion;
const newCriterion = filter.makeCriterion(option.type) as FolderCriterion;
return newCriterion;
}, [option.type, filter]);
// if there are multiple values or excluded values, then we show none of the
// current values
const multipleSelected =
criterion.value.items.length > 1 || criterion.value.excluded.length > 0;
function onSelect(folder: IFolder) {
const c = criterion.clone() as FolderCriterion;
c.value = {
items: [{ id: folder.id, label: folder.path }],
depth: 0,
excluded: [],
};
const newCriteria = props.filter.criteria.filter(
(cc) => cc.criterionOption.type !== option.type
);
if (c.isValid()) newCriteria.push(c);
setFilter(props.filter.setCriteria(newCriteria));
}
function onSelectSubfolders() {
const c = criterion.clone() as FolderCriterion;
c.value = {
items: c.value?.items ?? [],
depth: -1,
excluded: c.value?.excluded ?? [],
};
setFilter(props.filter.replaceCriteria(option.type, [c]));
}
const onUnselect = useCallback(
(i: Option) => {
if (i.className === "modifier-object") {
// subfolders option
const c = criterion.clone() as FolderCriterion;
c.value = {
items: c.value?.items ?? [],
depth: 0,
excluded: c.value?.excluded ?? [],
};
setFilter(props.filter.replaceCriteria(option.type, [c]));
return;
}
setFilter(props.filter.removeCriterion(option.type));
},
[props.filter, setFilter, option.type, criterion]
);
function onEnter() {
if (!query) return;
// if there is a single folder that matches the query, select it
const matchingFolders = getMatchingFolders(folderMap, query);
if (matchingFolders.length === 1) {
onSelect(matchingFolders[0]);
}
}
const subDirsSelected = criterion.value?.depth === -1;
const selectedList = useMemo(() => {
if (multipleSelected) {
return null;
}
const selected: Option[] =
criterion.value?.items.map((item) => ({
id: item.id,
label: item.label,
})) ?? [];
if (subDirsSelected) {
selected.push({
id: "subfolders",
label: "(" + intl.formatMessage({ id: "sub_folders" }) + ")",
className: "modifier-object",
});
}
return <SelectedList items={selected} onUnselect={onUnselect} />;
}, [intl, multipleSelected, subDirsSelected, criterion, onUnselect]);
const modifierItem = criterion.value.items.length > 0 &&
!multipleSelected &&
!subDirsSelected && (
<li className="unselected-object modifier-object">
<a onClick={onSelectSubfolders}>
<span>
<Icon className={`fa-fw include-button`} icon={faPlus} />
(<FormattedMessage id="sub_folders" />)
</span>
</a>
</li>
);
return (
<SidebarSection
{...props}
outsideCollapse={selectedList}
onOpen={onOpen}
className="sidebar-list-filter sidebar-folder-filter"
>
<ClearableInput
value={displayQuery}
setValue={(v) => onQueryChange(v)}
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
onEnter={onEnter}
/>
<FolderSelector
folderMap={folderMap}
onToggleExpanded={onToggleExpanded}
preListContent={modifierItem}
onSelect={(f) => onSelect(f)}
/>
</SidebarSection>
);
};

View file

@ -391,10 +391,17 @@ export function useCandidates(props: {
const defaultModifier = getDefaultModifier(singleValue);
const candidates = useMemo(() => {
return (results ?? []).map((r) => ({
id: r.id,
label: r.label,
}));
}, [results]);
const modifierCandidates = useMemo(() => {
const hierarchicalCandidate =
hierarchical && (criterion.value as IHierarchicalLabelValue).depth !== -1;
const modifierCandidates: Option[] = getModifierCandidates({
return getModifierCandidates({
modifier,
defaultModifier,
hasSelected: selected.length > 0,
@ -416,19 +423,11 @@ export function useCandidates(props: {
canExclude: false,
};
});
return modifierCandidates.concat(
(results ?? []).map((r) => ({
id: r.id,
label: r.label,
}))
);
}, [
defaultModifier,
intl,
modifier,
singleValue,
results,
selected,
excluded,
criterion.value,
@ -436,7 +435,7 @@ export function useCandidates(props: {
includeSubMessageID,
]);
return candidates;
return { candidates, modifierCandidates };
}
export function useLabeledIdFilterState(props: {
@ -481,7 +480,7 @@ export function useLabeledIdFilterState(props: {
includeSubMessageID,
});
const candidates = useCandidates({
const { candidates, modifierCandidates } = useCandidates({
criterion,
queryResults,
selected,
@ -497,6 +496,7 @@ export function useLabeledIdFilterState(props: {
return {
candidates,
modifierCandidates,
onSelect,
onUnselect,
selected,

View file

@ -19,7 +19,12 @@ import {
ModifierCriterion,
IHierarchicalLabeledIdCriterion,
} from "src/models/list-filter/criteria/criterion";
import { defineMessages, MessageDescriptor, useIntl } from "react-intl";
import {
defineMessages,
FormattedMessage,
MessageDescriptor,
useIntl,
} from "react-intl";
import { CriterionModifier } from "src/core/generated-graphql";
import { keyboardClickHandler } from "src/utils/keyboard";
import { useDebounce } from "src/hooks/debounce";
@ -118,7 +123,9 @@ const UnselectedItem: React.FC<{
onKeyDown={(e) => e.stopPropagation()}
className="minimal exclude-button"
>
<span className="exclude-button-text">exclude</span>
<span className="exclude-button-text">
<FormattedMessage id="actions.exclude_lowercase" />
</span>
{excludeIcon}
</Button>
)}
@ -240,12 +247,19 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
onSetModifier(defaultModifier);
}
function onEnter() {
if (objects.length === 1) {
onSelect(objects[0], false);
}
}
return (
<div className="selectable-filter">
<ClearableInput
focus={inputFocus}
value={query}
setValue={(v) => onQueryChange(v)}
onEnter={onEnter}
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
/>
<ul>
@ -450,6 +464,42 @@ export const ObjectsFilter = <
);
};
export const DepthSelector: React.FC<{
depth: number | undefined;
onDepthChanged: (depth: number) => void;
id: string;
label?: React.ReactNode;
placeholder?: string;
disabled?: boolean;
}> = ({ depth, onDepthChanged, id, label, disabled, placeholder }) => {
return (
<Form.Group>
<Form.Group>
<Form.Check
id={id}
checked={depth !== 0}
label={label}
onChange={() => onDepthChanged(depth !== 0 ? 0 : -1)}
disabled={disabled}
/>
</Form.Group>
{depth !== 0 && (
<Form.Group>
<NumberField
className="btn-secondary"
placeholder={placeholder}
onChange={(e) =>
onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1)
}
defaultValue={depth !== -1 ? depth : ""}
min="1"
/>
</Form.Group>
)}
</Form.Group>
);
};
interface IHierarchicalObjectsFilter<T extends IHierarchicalLabeledIdCriterion>
extends IObjectsFilter<T> {}
@ -497,38 +547,15 @@ export const HierarchicalObjectsFilter = <
}
return (
<Form>
<Form.Group>
<Form.Check
id={criterionOptionTypeToIncludeID()}
checked={
criterion.modifier !== CriterionModifier.Equals &&
criterion.value.depth !== 0
}
label={intl.formatMessage(criterionOptionTypeToIncludeUIString())}
onChange={() => onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)}
disabled={criterion.modifier === CriterionModifier.Equals}
/>
</Form.Group>
{criterion.value.depth !== 0 && (
<Form.Group>
<NumberField
className="btn-secondary"
placeholder={intl.formatMessage(messages.studio_depth)}
onChange={(e) =>
onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1)
}
defaultValue={
criterion.value && criterion.value.depth !== -1
? criterion.value.depth
: ""
}
min="1"
/>
</Form.Group>
)}
<div>
<DepthSelector
depth={criterion.value.depth}
onDepthChanged={onDepthChanged}
id={criterionOptionTypeToIncludeID()}
label={intl.formatMessage(criterionOptionTypeToIncludeUIString())}
placeholder={intl.formatMessage(messages.studio_depth)}
/>
<ObjectsFilter {...props} />
</Form>
</div>
);
};

View file

@ -182,7 +182,8 @@ const QueryField: React.FC<{
focus: ReturnType<typeof useFocus>;
value: string;
setValue: (query: string) => void;
}> = ({ focus, value, setValue }) => {
onEnter?: () => void;
}> = ({ focus, value, setValue, onEnter }) => {
const intl = useIntl();
const [displayQuery, setDisplayQuery] = useState(value);
@ -206,6 +207,7 @@ const QueryField: React.FC<{
value={displayQuery}
setValue={(v) => onQueryChange(v)}
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
onEnter={onEnter}
/>
);
};
@ -214,6 +216,7 @@ interface IQueryableProps {
inputFocus?: ReturnType<typeof useFocus>;
query?: string;
setQuery?: (query: string) => void;
onEnter?: () => void;
}
export const CandidateList: React.FC<
@ -227,6 +230,7 @@ export const CandidateList: React.FC<
inputFocus,
query,
setQuery,
onEnter,
items,
onSelect,
canExclude,
@ -242,6 +246,7 @@ export const CandidateList: React.FC<
focus={inputFocus}
value={query}
setValue={(v) => setQuery(v)}
onEnter={onEnter}
/>
)}
<ul>
@ -265,6 +270,7 @@ export const SidebarListFilter: React.FC<{
selected: Option[];
excluded?: Option[];
candidates: Option[];
modifierCandidates?: Option[];
singleValue?: boolean;
onSelect: (item: Option, exclude: boolean) => void;
onUnselect: (item: Option, exclude: boolean) => void;
@ -283,6 +289,7 @@ export const SidebarListFilter: React.FC<{
selected,
excluded,
candidates,
modifierCandidates,
onSelect,
onUnselect,
canExclude,
@ -324,6 +331,20 @@ export const SidebarListFilter: React.FC<{
}
}
function onEnter() {
if (candidates && candidates.length === 1) {
selectHook(candidates[0], false);
}
}
const items = useMemo(() => {
if (!modifierCandidates) {
return candidates;
}
return [...modifierCandidates, ...candidates];
}, [candidates, modifierCandidates]);
return (
<SidebarSection
className="sidebar-list-filter"
@ -350,13 +371,14 @@ export const SidebarListFilter: React.FC<{
>
{preCandidates ? <div className="extra">{preCandidates}</div> : null}
<CandidateList
items={candidates}
items={items}
onSelect={selectHook}
canExclude={canExclude}
inputFocus={inputFocus}
query={query}
setQuery={setQuery}
singleValue={singleValue}
onEnter={onEnter}
/>
{postCandidates ? <div className="extra">{postCandidates}</div> : null}
</SidebarSection>

View file

@ -234,14 +234,14 @@ input[type="range"].zoom-slider {
.saved-filter-item {
cursor: pointer;
height: 2em;
margin-bottom: 0.25rem;
min-height: 2em;
a {
align-items: center;
display: flex;
height: 2em;
justify-content: space-between;
min-height: 2em;
outline: none;
&:hover,
@ -507,7 +507,8 @@ input[type="range"].zoom-slider {
}
}
.selectable-filter ul {
.selectable-filter ul,
ul.selectable-list {
list-style-type: none;
margin-top: 0.5rem;
max-height: 300px;
@ -533,14 +534,14 @@ input[type="range"].zoom-slider {
.excluded-object,
.unselected-object {
cursor: pointer;
height: 2em;
margin-bottom: 0.25rem;
min-height: 2em;
a {
align-items: center;
display: flex;
height: 2em;
justify-content: space-between;
min-height: 2em;
outline: none;
&:hover,
@ -613,7 +614,8 @@ input[type="range"].zoom-slider {
margin-bottom: 0.5rem;
}
.sidebar-list-filter ul {
.sidebar-list-filter ul,
.folder-filter ul {
list-style-type: none;
margin-bottom: 0.25rem;
max-height: 300px;
@ -639,14 +641,14 @@ input[type="range"].zoom-slider {
.excluded-object,
.unselected-object {
cursor: pointer;
height: 2em;
margin-bottom: 0.25rem;
min-height: 2em;
a {
align-items: center;
display: flex;
height: 2em;
justify-content: space-between;
min-height: 2em;
outline: none;
&:hover,
@ -687,7 +689,7 @@ input[type="range"].zoom-slider {
}
&:hover {
background-color: inherit;
background-color: transparent;
}
&:hover .exclude-button-text,
@ -748,6 +750,29 @@ input[type="range"].zoom-slider {
}
}
.sidebar-folder-filter ul,
.folder-filter ul,
ul.selectable-list {
margin-top: 0.25rem;
.btn.expand-collapse {
font-size: 0.8rem;
padding-left: 0;
padding-right: 0.25rem;
text-align: left;
}
.empty .btn.expand-collapse {
visibility: hidden;
}
.selected-object a .selected-object-label {
font-size: 0.8em;
overflow-wrap: break-word;
white-space: normal;
}
}
.tilted {
transform: rotate(45deg);
}

View file

@ -58,6 +58,7 @@ import useFocus from "src/utils/focus";
import { useZoomKeybinds } from "../List/ZoomSlider";
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;
@ -305,6 +306,12 @@ const SidebarContent: React.FC<{
/>
<SidebarRatingFilter filter={filter} setFilter={setFilter} />
<SidebarDurationFilter filter={filter} setFilter={setFilter} />
<SidebarFolderFilter
text={<FormattedMessage id="folder" />}
filter={filter}
setFilter={setFilter}
sectionID="folder"
/>
<SidebarBooleanFilter
title={<FormattedMessage id="hasMarkers" />}
data-type={HasMarkersCriterionOption.type}

View file

@ -10,6 +10,7 @@ interface IClearableInput {
className?: string;
value: string;
setValue: (value: string) => void;
onEnter?: () => void;
focus?: ReturnType<typeof useFocus>;
placeholder?: string;
}
@ -18,6 +19,7 @@ export const ClearableInput: React.FC<IClearableInput> = ({
className,
value,
setValue,
onEnter,
focus,
placeholder,
}) => {
@ -43,6 +45,9 @@ export const ClearableInput: React.FC<IClearableInput> = ({
if (e.key === "Escape") {
queryRef.current?.blur();
}
if (e.key === "Enter" && onEnter) {
onEnter();
}
}
return (

View file

@ -2,6 +2,7 @@ import {
faChevronDown,
faChevronRight,
faChevronUp,
IconDefinition,
} from "@fortawesome/free-solid-svg-icons";
import React, { useEffect, useState } from "react";
import { Button, Collapse, CollapseProps } from "react-bootstrap";
@ -55,14 +56,21 @@ export const CollapseButton: React.FC<React.PropsWithChildren<IProps>> = (
export const ExpandCollapseButton: React.FC<{
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
}> = ({ collapsed, setCollapsed }) => {
const buttonIcon = collapsed ? faChevronDown : faChevronUp;
collapsedIcon?: IconDefinition;
notCollapsedIcon?: IconDefinition;
}> = ({ collapsedIcon, notCollapsedIcon, collapsed, setCollapsed }) => {
const buttonIcon = collapsed
? collapsedIcon ?? faChevronDown
: notCollapsedIcon ?? faChevronUp;
return (
<span className="detail-expand-collapse">
<Button
className="minimal expand-collapse"
onClick={() => setCollapsed(!collapsed)}
onClick={(e) => {
setCollapsed(!collapsed);
e.stopPropagation();
}}
>
<Icon icon={buttonIcon} fixedWidth />
</Button>

View file

@ -97,15 +97,17 @@ interface IContext {
export const SidebarStateContext = React.createContext<IContext | null>(null);
export interface ISidebarSectionProps {
text: React.ReactNode;
className?: string;
outsideCollapse?: React.ReactNode;
onOpen?: () => void;
// used to store open/closed state in SidebarStateContext
sectionID?: string;
}
export const SidebarSection: React.FC<
PropsWithChildren<{
text: React.ReactNode;
className?: string;
outsideCollapse?: React.ReactNode;
onOpen?: () => void;
// used to store open/closed state in SidebarStateContext
sectionID?: string;
}>
PropsWithChildren<ISidebarSectionProps>
> = ({
className = "",
text,

View file

@ -515,6 +515,21 @@ export const useFindSavedFilters = (mode?: GQL.FilterMode) =>
variables: { mode },
});
export const queryFindSubFolders = (id: string) =>
client.query<GQL.FindFoldersForQueryQuery>({
query: GQL.FindFoldersForQueryDocument,
variables: {
folder_filter: {
parent_folder: { value: id, modifier: GQL.CriterionModifier.Equals },
},
filter: {
per_page: -1,
sort: "basename",
direction: GQL.SortDirectionEnum.Asc,
},
},
});
/// Object Mutations
// Increases/decreases the given field of the Stats query by diff

View file

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */
import { debounce, DebouncedFunc, DebounceSettings } from "lodash-es";
import { useCallback, useRef } from "react";
import { useCallback, useRef, useState } from "react";
export function useDebounce<T extends (...args: any) => any>(
fn: T,
@ -21,3 +21,30 @@ export function useDebounce<T extends (...args: any) => any>(
[wait, options?.leading, options?.trailing, options?.maxWait]
);
}
export function useDebouncedState<T>(
initialValue: T,
setValue: (v: T) => void,
wait?: number
): [T, (v: T) => void, (v: T) => void] {
const [displayedState, setDisplayedState] = useState(initialValue);
const debouncedSetValue = useDebounce(setValue, wait);
const onChange = useCallback(
(input: T) => {
setDisplayedState(input);
debouncedSetValue(input);
},
[debouncedSetValue, setDisplayedState]
);
const setInstant = useCallback(
(v: T) => {
setDisplayedState(v);
setValue(v);
},
[setValue]
);
return [displayedState, onChange, setInstant];
}

View file

@ -52,6 +52,7 @@
"edit_entity": "Edit {entityType}",
"enable": "Enable",
"encoding_image": "Encoding image…",
"exclude_lowercase": "exclude",
"export": "Export",
"export_all": "Export all…",
"find": "Find",
@ -1225,6 +1226,7 @@
"image_index": "Image #",
"images": "Images",
"include_parent_tags": "Include parent tags",
"include_sub_folders": "Include sub-folders",
"include_sub_group_content": "Include sub-group content",
"include_sub_groups": "Include sub-groups",
"include_sub_studio_content": "Include sub-studio content",
@ -1327,6 +1329,7 @@
"next": "Next",
"previous": "Previous"
},
"parent_folder": "Parent Folder",
"parent_of": "Parent of {children}",
"parent_studio": "Parent Studio",
"parent_studios": "Parent Studios",
@ -1578,6 +1581,8 @@
},
"studio_tags": "Studio Tags",
"studios": "Studios",
"sub_folder_depth": "Sub folder depth (empty for all)",
"sub_folders": "Sub folders",
"sub_group": "Sub-Group",
"sub_group_count": "Sub-Group Count",
"sub_group_of": "Sub-group of {parent}",

View file

@ -251,6 +251,7 @@ export type InputType =
| "scene_tags"
| "groups"
| "galleries"
| "folders"
| undefined;
type MakeCriterionFn = (

View file

@ -0,0 +1,52 @@
import { CriterionModifier } from "src/core/generated-graphql";
import {
ModifierCriterionOption,
IHierarchicalLabeledIdCriterion,
} from "./criterion";
const modifierOptions = [CriterionModifier.Includes];
const defaultModifier = CriterionModifier.Includes;
const inputType = "folders";
export const FolderCriterionOption = new ModifierCriterionOption({
messageID: "folder",
type: "folder",
modifierOptions,
defaultModifier,
inputType,
makeCriterion: () => new FolderCriterion(),
});
// for galleries, we should use parent folder to distinguish between gallery folder
// and parent folder of the gallery folder
export const ParentFolderCriterionOption = new ModifierCriterionOption({
messageID: "parent_folder",
type: "parent_folder",
modifierOptions,
defaultModifier,
inputType,
makeCriterion: () => new ParentFolderCriterion(),
});
export class FolderCriterion extends IHierarchicalLabeledIdCriterion {
constructor() {
super(FolderCriterionOption);
}
public applyToCriterionInput(input: Record<string, unknown>) {
input.files_filter = {
parent_folder: this.toCriterionInput(),
};
}
}
export class ParentFolderCriterion extends IHierarchicalLabeledIdCriterion {
constructor() {
super(ParentFolderCriterionOption);
}
public applyToCriterionInput(input: Record<string, unknown>) {
input.parent_folder = this.toCriterionInput();
}
}

View file

@ -22,6 +22,7 @@ import { DisplayMode } from "./types";
import { RatingCriterionOption } from "./criteria/rating";
import { PathCriterionOption } from "./criteria/path";
import { CustomFieldsCriterionOption } from "./criteria/custom-fields";
import { ParentFolderCriterionOption } from "./criteria/folder";
const defaultSortBy = "path";
@ -53,6 +54,7 @@ const criterionOptions = [
createStringCriterionOption("details"),
createStringCriterionOption("photographer"),
PathCriterionOption,
ParentFolderCriterionOption,
createStringCriterionOption("checksum", "media_info.md5"),
RatingCriterionOption,
OrganizedCriterionOption,

View file

@ -23,6 +23,7 @@ import { DisplayMode } from "./types";
import { GalleriesCriterionOption } from "./criteria/galleries";
import { PhashCriterionOption } from "./criteria/phash";
import { CustomFieldsCriterionOption } from "./criteria/custom-fields";
import { FolderCriterionOption } from "./criteria/folder";
const defaultSortBy = "path";
@ -54,6 +55,7 @@ const criterionOptions = [
createMandatoryStringCriterionOption("checksum", "media_info.md5"),
PhashCriterionOption,
PathCriterionOption,
FolderCriterionOption,
GalleriesCriterionOption,
OrganizedCriterionOption,
createMandatoryNumberCriterionOption("o_counter", "o_count", {

View file

@ -36,6 +36,7 @@ import { RatingCriterionOption } from "./criteria/rating";
import { PathCriterionOption } from "./criteria/path";
import { OrientationCriterionOption } from "./criteria/orientation";
import { CustomFieldsCriterionOption } from "./criteria/custom-fields";
import { FolderCriterionOption } from "./criteria/folder";
const defaultSortBy = "date";
const sortByOptions = [
@ -96,6 +97,7 @@ const criterionOptions = [
createStringCriterionOption("title"),
createStringCriterionOption("code", "scene_code"),
PathCriterionOption,
FolderCriterionOption,
createStringCriterionOption("details"),
createStringCriterionOption("director"),
createMandatoryStringCriterionOption("oshash", "media_info.oshash"),

View file

@ -223,4 +223,6 @@ export type CriterionType =
| "disambiguation"
| "has_chapters"
| "sort_name"
| "custom_fields";
| "custom_fields"
| "folder"
| "parent_folder";