mirror of
https://github.com/stashapp/stash.git
synced 2026-06-11 07:41:08 -05:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9866796a14 | ||
|
|
35ab112da3 | ||
|
|
1b2b4c5221 | ||
|
|
336fa3b70e | ||
|
|
299e1ac1f9 | ||
|
|
fb7bd89834 | ||
|
|
f04be76224 | ||
|
|
db79cf9bb1 | ||
|
|
90baa31ee3 | ||
|
|
9b8300e882 | ||
|
|
d70ff551d4 | ||
|
|
1dccecc39c | ||
|
|
648875995c | ||
|
|
96b5a9448c | ||
|
|
fda97e7f6c |
@@ -201,7 +201,7 @@ func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.So
|
||||
}
|
||||
|
||||
// TODO - this should happen after any scene is scraped
|
||||
if err := r.matchScenesRelationships(ctx, ret, *source.StashBoxEndpoint); err != nil {
|
||||
if err := r.matchScenesRelationships(ctx, ret, b.Endpoint); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
@@ -245,7 +245,7 @@ func (r *queryResolver) ScrapeMultiScenes(ctx context.Context, source scraper.So
|
||||
// just flatten the slice and pass it in
|
||||
flat := sliceutil.Flatten(ret)
|
||||
|
||||
if err := r.matchScenesRelationships(ctx, flat, *source.StashBoxEndpoint); err != nil {
|
||||
if err := r.matchScenesRelationships(ctx, flat, b.Endpoint); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.S
|
||||
if len(ret) > 0 {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
for _, studio := range ret {
|
||||
if err := match.ScrapedStudioHierarchy(ctx, r.repository.Studio, studio, *source.StashBoxEndpoint); err != nil {
|
||||
if err := match.ScrapedStudioHierarchy(ctx, r.repository.Studio, studio, b.Endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ func (t *GenerateCoverTask) Start(ctx context.Context) {
|
||||
return t.Scene.LoadPrimaryFile(ctx, r.File)
|
||||
}); err != nil {
|
||||
logger.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if !required {
|
||||
|
||||
@@ -462,6 +462,7 @@ type ScrapedGroup struct {
|
||||
Date *string `json:"date"`
|
||||
Rating *string `json:"rating"`
|
||||
Director *string `json:"director"`
|
||||
URL *string `json:"url"` // included for backward compatibility
|
||||
URLs []string `json:"urls"`
|
||||
Synopsis *string `json:"synopsis"`
|
||||
Studio *ScrapedStudio `json:"studio"`
|
||||
|
||||
@@ -873,50 +873,55 @@ func (r mappedResult) apply(dest interface{}) {
|
||||
|
||||
func mapFieldValue(destVal reflect.Value, key string, value interface{}) error {
|
||||
field := destVal.FieldByName(key)
|
||||
|
||||
if !field.IsValid() {
|
||||
return fmt.Errorf("field %s does not exist on %s", key, destVal.Type().Name())
|
||||
}
|
||||
|
||||
if !field.CanSet() {
|
||||
return fmt.Errorf("field %s cannot be set on %s", key, destVal.Type().Name())
|
||||
}
|
||||
|
||||
fieldType := field.Type()
|
||||
|
||||
if field.IsValid() && field.CanSet() {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
// if the field is a pointer to a string, then we need to convert the string to a pointer
|
||||
// if the field is a string slice, then we need to convert the string to a slice
|
||||
switch {
|
||||
case fieldType.Kind() == reflect.String:
|
||||
field.SetString(v)
|
||||
case fieldType.Kind() == reflect.Ptr && fieldType.Elem().Kind() == reflect.String:
|
||||
ptr := reflect.New(fieldType.Elem())
|
||||
ptr.Elem().SetString(v)
|
||||
field.Set(ptr)
|
||||
case fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.String:
|
||||
field.Set(reflect.ValueOf([]string{v}))
|
||||
default:
|
||||
return fmt.Errorf("cannot convert %T to %s", value, fieldType)
|
||||
}
|
||||
case []string:
|
||||
// expect the field to be a string slice
|
||||
if fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.String {
|
||||
field.Set(reflect.ValueOf(v))
|
||||
} else {
|
||||
return fmt.Errorf("cannot convert %T to %s", value, fieldType)
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
// if the field is a pointer to a string, then we need to convert the string to a pointer
|
||||
// if the field is a string slice, then we need to convert the string to a slice
|
||||
switch {
|
||||
case fieldType.Kind() == reflect.String:
|
||||
field.SetString(v)
|
||||
case fieldType.Kind() == reflect.Ptr && fieldType.Elem().Kind() == reflect.String:
|
||||
ptr := reflect.New(fieldType.Elem())
|
||||
ptr.Elem().SetString(v)
|
||||
field.Set(ptr)
|
||||
case fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.String:
|
||||
field.Set(reflect.ValueOf([]string{v}))
|
||||
default:
|
||||
// fallback to reflection
|
||||
reflectValue := reflect.ValueOf(value)
|
||||
reflectValueType := reflectValue.Type()
|
||||
|
||||
switch {
|
||||
case reflectValueType.ConvertibleTo(fieldType):
|
||||
field.Set(reflectValue.Convert(fieldType))
|
||||
case fieldType.Kind() == reflect.Pointer && reflectValueType.ConvertibleTo(fieldType.Elem()):
|
||||
ptr := reflect.New(fieldType.Elem())
|
||||
ptr.Elem().Set(reflectValue.Convert(fieldType.Elem()))
|
||||
field.Set(ptr)
|
||||
default:
|
||||
return fmt.Errorf("cannot convert %T to %s", value, fieldType)
|
||||
}
|
||||
return fmt.Errorf("cannot convert %T to %s", value, fieldType)
|
||||
}
|
||||
case []string:
|
||||
// expect the field to be a string slice
|
||||
if fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.String {
|
||||
field.Set(reflect.ValueOf(v))
|
||||
} else {
|
||||
return fmt.Errorf("cannot convert %T to %s", value, fieldType)
|
||||
}
|
||||
default:
|
||||
// fallback to reflection
|
||||
reflectValue := reflect.ValueOf(value)
|
||||
reflectValueType := reflectValue.Type()
|
||||
|
||||
switch {
|
||||
case reflectValueType.ConvertibleTo(fieldType):
|
||||
field.Set(reflectValue.Convert(fieldType))
|
||||
case fieldType.Kind() == reflect.Pointer && reflectValueType.ConvertibleTo(fieldType.Elem()):
|
||||
ptr := reflect.New(fieldType.Elem())
|
||||
ptr.Elem().Set(reflectValue.Convert(fieldType.Elem()))
|
||||
field.Set(ptr)
|
||||
default:
|
||||
return fmt.Errorf("cannot convert %T to %s", value, fieldType)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("field does not exist or cannot be set")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -143,6 +143,21 @@ func (c Cache) postScrapeMovie(ctx context.Context, m models.ScrapedMovie, exclu
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// populate URL/URLs
|
||||
// if URLs are provided, only use those
|
||||
if len(m.URLs) > 0 {
|
||||
m.URL = &m.URLs[0]
|
||||
} else {
|
||||
urls := []string{}
|
||||
if m.URL != nil {
|
||||
urls = append(urls, *m.URL)
|
||||
}
|
||||
|
||||
if len(urls) > 0 {
|
||||
m.URLs = urls
|
||||
}
|
||||
}
|
||||
|
||||
// post-process - set the image if applicable
|
||||
if err := setMovieFrontImage(ctx, c.client, &m, c.globalConfig); err != nil {
|
||||
logger.Warnf("could not set front image using URL %s: %v", *m.FrontImage, err)
|
||||
@@ -175,6 +190,21 @@ func (c Cache) postScrapeGroup(ctx context.Context, m models.ScrapedGroup, exclu
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// populate URL/URLs
|
||||
// if URLs are provided, only use those
|
||||
if len(m.URLs) > 0 {
|
||||
m.URL = &m.URLs[0]
|
||||
} else {
|
||||
urls := []string{}
|
||||
if m.URL != nil {
|
||||
urls = append(urls, *m.URL)
|
||||
}
|
||||
|
||||
if len(urls) > 0 {
|
||||
m.URLs = urls
|
||||
}
|
||||
}
|
||||
|
||||
// post-process - set the image if applicable
|
||||
if err := setGroupFrontImage(ctx, c.client, &m, c.globalConfig); err != nil {
|
||||
logger.Warnf("could not set front image using URL %s: %v", *m.FrontImage, err)
|
||||
|
||||
@@ -319,7 +319,7 @@ func (qb *sceneFilterHandler) isMissingCriterionHandler(isMissing *string) crite
|
||||
f.addWhere("galleries_join.scene_id IS NULL")
|
||||
case "studio":
|
||||
f.addWhere("scenes.studio_id IS NULL")
|
||||
case "movie":
|
||||
case "movie", "group":
|
||||
sceneRepository.groups.join(f, "groups_join", "scenes.id")
|
||||
f.addWhere("groups_join.scene_id IS NULL")
|
||||
case "performers":
|
||||
|
||||
@@ -43,6 +43,7 @@ import cx from "classnames";
|
||||
import { useRatingKeybinds } from "src/hooks/keybinds";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { TruncatedText } from "src/components/Shared/TruncatedText";
|
||||
import { goBackOrReplace } from "src/utils/history";
|
||||
|
||||
interface IProps {
|
||||
gallery: GQL.GalleryDataFragment;
|
||||
@@ -167,7 +168,7 @@ export const GalleryPage: React.FC<IProps> = ({ gallery, add }) => {
|
||||
function onDeleteDialogClosed(deleted: boolean) {
|
||||
setIsDeleteAlertOpen(false);
|
||||
if (deleted) {
|
||||
history.goBack();
|
||||
goBackOrReplace(history, "/galleries");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ export const AddSubGroupsDialog: React.FC<IListOperationProps> = (
|
||||
onUpdate={(input) => setEntries(input)}
|
||||
excludeIDs={excludeIDs}
|
||||
filterHook={filterHook}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Form>
|
||||
</ModalComponent>
|
||||
|
||||
@@ -43,6 +43,7 @@ import { Button, Tab, Tabs } from "react-bootstrap";
|
||||
import { GroupSubGroupsPanel } from "./GroupSubGroupsPanel";
|
||||
import { GroupPerformersPanel } from "./GroupPerformersPanel";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
import { goBackOrReplace } from "src/utils/history";
|
||||
|
||||
const validTabs = ["default", "scenes", "performers", "subgroups"] as const;
|
||||
type TabKey = (typeof validTabs)[number];
|
||||
@@ -276,7 +277,7 @@ const GroupPage: React.FC<IProps> = ({ group, tabKey }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
history.goBack();
|
||||
goBackOrReplace(history, "/groups");
|
||||
}
|
||||
|
||||
function toggleEditing(value?: boolean) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from "react";
|
||||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { GroupList } from "../GroupList";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
@@ -101,6 +101,18 @@ interface IGroupSubGroupsPanel {
|
||||
group: GQL.GroupDataFragment;
|
||||
}
|
||||
|
||||
const defaultFilter = (() => {
|
||||
const sortBy = "sub_group_order";
|
||||
const ret = new ListFilterModel(GQL.FilterMode.Groups, undefined, {
|
||||
defaultSortBy: sortBy,
|
||||
});
|
||||
|
||||
// unset the sort by so that its not included in the URL
|
||||
ret.sortBy = undefined;
|
||||
|
||||
return ret;
|
||||
})();
|
||||
|
||||
export const GroupSubGroupsPanel: React.FC<IGroupSubGroupsPanel> = ({
|
||||
active,
|
||||
group,
|
||||
@@ -114,18 +126,6 @@ export const GroupSubGroupsPanel: React.FC<IGroupSubGroupsPanel> = ({
|
||||
|
||||
const filterHook = useContainingGroupFilterHook(group);
|
||||
|
||||
const defaultFilter = useMemo(() => {
|
||||
const sortBy = "sub_group_order";
|
||||
const ret = new ListFilterModel(GQL.FilterMode.Groups, undefined, {
|
||||
defaultSortBy: sortBy,
|
||||
});
|
||||
|
||||
// unset the sort by so that its not included in the URL
|
||||
ret.sortBy = undefined;
|
||||
|
||||
return ret;
|
||||
}, []);
|
||||
|
||||
async function removeSubGroups(
|
||||
result: GQL.FindGroupsQueryResult,
|
||||
filter: ListFilterModel,
|
||||
|
||||
@@ -34,6 +34,7 @@ import TextUtils from "src/utils/text";
|
||||
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
||||
import cx from "classnames";
|
||||
import { TruncatedText } from "src/components/Shared/TruncatedText";
|
||||
import { goBackOrReplace } from "src/utils/history";
|
||||
|
||||
interface IProps {
|
||||
image: GQL.ImageDataFragment;
|
||||
@@ -156,7 +157,7 @@ const ImagePage: React.FC<IProps> = ({ image }) => {
|
||||
function onDeleteDialogClosed(deleted: boolean) {
|
||||
setIsDeleteAlertOpen(false);
|
||||
if (deleted) {
|
||||
history.goBack();
|
||||
goBackOrReplace(history, "/images");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ interface ICriterionList {
|
||||
optionSelected: (o?: CriterionOption) => void;
|
||||
onRemoveCriterion: (c: string) => void;
|
||||
onTogglePin: (c: CriterionOption) => void;
|
||||
externallySelected?: boolean;
|
||||
}
|
||||
|
||||
const CriterionOptionList: React.FC<ICriterionList> = ({
|
||||
@@ -62,6 +63,7 @@ const CriterionOptionList: React.FC<ICriterionList> = ({
|
||||
optionSelected,
|
||||
onRemoveCriterion,
|
||||
onTogglePin,
|
||||
externallySelected = false,
|
||||
}) => {
|
||||
const prevCriterion = usePrevious(currentCriterion);
|
||||
|
||||
@@ -101,14 +103,19 @@ const CriterionOptionList: React.FC<ICriterionList> = ({
|
||||
// scrolling to the current criterion doesn't work well when the
|
||||
// dialog is already open, so limit to when we click on the
|
||||
// criterion from the external tags
|
||||
if (!scrolled.current && type && criteriaRefs[type]?.current) {
|
||||
if (
|
||||
externallySelected &&
|
||||
!scrolled.current &&
|
||||
type &&
|
||||
criteriaRefs[type]?.current
|
||||
) {
|
||||
criteriaRefs[type].current!.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
scrolled.current = true;
|
||||
}
|
||||
}, [currentCriterion, criteriaRefs, type]);
|
||||
}, [externallySelected, currentCriterion, criteriaRefs, type]);
|
||||
|
||||
function getReleventCriterion(t: CriterionType) {
|
||||
if (currentCriterion?.criterionOption.type === t) {
|
||||
@@ -549,6 +556,7 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
|
||||
selected={criterion?.criterionOption}
|
||||
onRemoveCriterion={(c) => removeCriterionString(c)}
|
||||
onTogglePin={(c) => onTogglePinFilter(c)}
|
||||
externallySelected={!!editingCriterion}
|
||||
/>
|
||||
{criteria.length > 0 && (
|
||||
<div>
|
||||
|
||||
@@ -54,6 +54,7 @@ interface ISidebarFilter {
|
||||
option: CriterionOption;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
sectionID?: string;
|
||||
}
|
||||
|
||||
export const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({
|
||||
@@ -61,6 +62,7 @@ export const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({
|
||||
option,
|
||||
filter,
|
||||
setFilter,
|
||||
sectionID,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -127,6 +129,7 @@ export const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({
|
||||
onUnselect={onUnselect}
|
||||
selected={selected}
|
||||
singleValue
|
||||
sectionID={sectionID}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,8 @@ import ScreenUtils from "src/utils/screen";
|
||||
import Mousetrap from "mousetrap";
|
||||
import { Button } from "react-bootstrap";
|
||||
|
||||
const savedFiltersSectionID = "saved-filters";
|
||||
|
||||
export const FilteredSidebarHeader: React.FC<{
|
||||
sidebarOpen: boolean;
|
||||
showEditFilter: () => void;
|
||||
@@ -60,6 +62,7 @@ export const FilteredSidebarHeader: React.FC<{
|
||||
<SidebarSection
|
||||
className="sidebar-saved-filters"
|
||||
text={<FormattedMessage id="search_filter.saved_filters" />}
|
||||
sectionID={savedFiltersSectionID}
|
||||
>
|
||||
<SidebarSavedFilterList
|
||||
filter={filter}
|
||||
|
||||
@@ -110,7 +110,8 @@ export const SidebarPerformersFilter: React.FC<{
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
filterHook?: (f: ListFilterModel) => ListFilterModel;
|
||||
}> = ({ title, option, filter, setFilter, filterHook }) => {
|
||||
sectionID?: string;
|
||||
}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => {
|
||||
const state = useLabeledIdFilterState({
|
||||
filter,
|
||||
setFilter,
|
||||
@@ -119,7 +120,7 @@ export const SidebarPerformersFilter: React.FC<{
|
||||
useQuery: usePerformerQueryFilter,
|
||||
});
|
||||
|
||||
return <SidebarListFilter {...state} title={title} />;
|
||||
return <SidebarListFilter {...state} title={title} sectionID={sectionID} />;
|
||||
};
|
||||
|
||||
export default PerformersFilter;
|
||||
|
||||
@@ -77,6 +77,7 @@ interface ISidebarFilter {
|
||||
option: CriterionOption;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
sectionID?: string;
|
||||
}
|
||||
|
||||
const any = "any";
|
||||
@@ -87,6 +88,7 @@ export const SidebarRatingFilter: React.FC<ISidebarFilter> = ({
|
||||
option,
|
||||
filter,
|
||||
setFilter,
|
||||
sectionID,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -199,6 +201,7 @@ export const SidebarRatingFilter: React.FC<ISidebarFilter> = ({
|
||||
singleValue
|
||||
preCandidates={ratingValue === null ? ratingStars : undefined}
|
||||
preSelected={ratingValue !== null ? ratingStars : undefined}
|
||||
sectionID={sectionID}
|
||||
/>
|
||||
<div></div>
|
||||
</>
|
||||
|
||||
@@ -275,7 +275,8 @@ export const SidebarListFilter: React.FC<{
|
||||
postSelected?: React.ReactNode;
|
||||
preCandidates?: React.ReactNode;
|
||||
postCandidates?: React.ReactNode;
|
||||
onOpen?: () => void;
|
||||
// used to store open/closed state in SidebarStateContext
|
||||
sectionID?: string;
|
||||
}> = ({
|
||||
title,
|
||||
selected,
|
||||
@@ -291,7 +292,7 @@ export const SidebarListFilter: React.FC<{
|
||||
postCandidates,
|
||||
preSelected,
|
||||
postSelected,
|
||||
onOpen,
|
||||
sectionID,
|
||||
}) => {
|
||||
// TODO - sort items?
|
||||
|
||||
@@ -325,6 +326,7 @@ export const SidebarListFilter: React.FC<{
|
||||
<SidebarSection
|
||||
className="sidebar-list-filter"
|
||||
text={title}
|
||||
sectionID={sectionID}
|
||||
outsideCollapse={
|
||||
<>
|
||||
{preSelected ? <div className="extra">{preSelected}</div> : null}
|
||||
@@ -342,7 +344,6 @@ export const SidebarListFilter: React.FC<{
|
||||
{postSelected ? <div className="extra">{postSelected}</div> : null}
|
||||
</>
|
||||
}
|
||||
onOpen={onOpen}
|
||||
>
|
||||
{preCandidates ? <div className="extra">{preCandidates}</div> : null}
|
||||
<CandidateList
|
||||
|
||||
@@ -98,7 +98,8 @@ export const SidebarStudiosFilter: React.FC<{
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
filterHook?: (f: ListFilterModel) => ListFilterModel;
|
||||
}> = ({ title, option, filter, setFilter, filterHook }) => {
|
||||
sectionID?: string;
|
||||
}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => {
|
||||
const state = useLabeledIdFilterState({
|
||||
filter,
|
||||
setFilter,
|
||||
@@ -110,7 +111,7 @@ export const SidebarStudiosFilter: React.FC<{
|
||||
includeSubMessageID: "subsidiary_studios",
|
||||
});
|
||||
|
||||
return <SidebarListFilter {...state} title={title} />;
|
||||
return <SidebarListFilter {...state} title={title} sectionID={sectionID} />;
|
||||
};
|
||||
|
||||
export default StudiosFilter;
|
||||
|
||||
@@ -103,7 +103,8 @@ export const SidebarTagsFilter: React.FC<{
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
filterHook?: (f: ListFilterModel) => ListFilterModel;
|
||||
}> = ({ title, option, filter, setFilter, filterHook }) => {
|
||||
sectionID?: string;
|
||||
}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => {
|
||||
const state = useLabeledIdFilterState({
|
||||
filter,
|
||||
setFilter,
|
||||
@@ -114,7 +115,7 @@ export const SidebarTagsFilter: React.FC<{
|
||||
includeSubMessageID: "sub_tags",
|
||||
});
|
||||
|
||||
return <SidebarListFilter {...state} title={title} />;
|
||||
return <SidebarListFilter {...state} title={title} sectionID={sectionID} />;
|
||||
};
|
||||
|
||||
export default TagsFilter;
|
||||
|
||||
@@ -16,22 +16,28 @@ import {
|
||||
faTrash,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import cx from "classnames";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export const OperationDropdown: React.FC<
|
||||
PropsWithChildren<{
|
||||
className?: string;
|
||||
menuPortalTarget?: HTMLElement;
|
||||
}>
|
||||
> = ({ className, children }) => {
|
||||
> = ({ className, menuPortalTarget, children }) => {
|
||||
if (!children) return null;
|
||||
|
||||
const menu = (
|
||||
<Dropdown.Menu className="bg-secondary text-white">
|
||||
{children}
|
||||
</Dropdown.Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown className={className} as={ButtonGroup}>
|
||||
<Dropdown.Toggle variant="secondary" id="more-menu">
|
||||
<Icon icon={faEllipsisH} />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="bg-secondary text-white">
|
||||
{children}
|
||||
</Dropdown.Menu>
|
||||
{menuPortalTarget ? createPortal(menu, menuPortalTarget) : menu}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,15 +23,6 @@ export const ListResultsHeader: React.FC<{
|
||||
}) => {
|
||||
return (
|
||||
<ButtonToolbar className={cx(className, "list-results-header")}>
|
||||
<div>
|
||||
<PaginationIndex
|
||||
loading={loading}
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
totalItems={totalCount}
|
||||
metadataByline={metadataByline}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<SortBySelect
|
||||
options={filter.options.sortByOptions}
|
||||
@@ -61,6 +52,16 @@ export const ListResultsHeader: React.FC<{
|
||||
onSetZoom={(zoom) => onChangeFilter(filter.setZoom(zoom))}
|
||||
/>
|
||||
</div>
|
||||
<div className="pagination-index-container">
|
||||
<PaginationIndex
|
||||
loading={loading}
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
totalItems={totalCount}
|
||||
metadataByline={metadataByline}
|
||||
/>
|
||||
</div>
|
||||
<div className="empty-space"></div>
|
||||
</ButtonToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,13 +4,15 @@ import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FilterTags } from "../List/FilterTags";
|
||||
import cx from "classnames";
|
||||
import { Button, ButtonToolbar } from "react-bootstrap";
|
||||
import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap";
|
||||
import { FilterButton } from "../List/Filters/FilterButton";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { SearchTermInput } from "../List/ListFilter";
|
||||
import { Criterion } from "src/models/list-filter/criteria/criterion";
|
||||
import { SidebarToggleButton } from "../Shared/Sidebar";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { SavedFilterDropdown } from "./SavedFilterList";
|
||||
import { View } from "./views";
|
||||
|
||||
export const ToolbarFilterSection: React.FC<{
|
||||
filter: ListFilterModel;
|
||||
@@ -21,6 +23,7 @@ export const ToolbarFilterSection: React.FC<{
|
||||
onRemoveAllCriterion: () => void;
|
||||
onEditSearchTerm: () => void;
|
||||
onRemoveSearchTerm: () => void;
|
||||
view?: View;
|
||||
}> = PatchComponent(
|
||||
"ToolbarFilterSection",
|
||||
({
|
||||
@@ -32,6 +35,7 @@ export const ToolbarFilterSection: React.FC<{
|
||||
onRemoveAllCriterion,
|
||||
onEditSearchTerm,
|
||||
onRemoveSearchTerm,
|
||||
view,
|
||||
}) => {
|
||||
const { criteria, searchTerm } = filter;
|
||||
|
||||
@@ -41,10 +45,19 @@ export const ToolbarFilterSection: React.FC<{
|
||||
<SearchTermInput filter={filter} onFilterUpdate={onSetFilter} />
|
||||
</div>
|
||||
<div className="filter-section">
|
||||
<FilterButton
|
||||
onClick={() => onEditCriterion()}
|
||||
count={criteria.length}
|
||||
/>
|
||||
<ButtonGroup>
|
||||
<SidebarToggleButton onClick={onToggleSidebar} />
|
||||
<SavedFilterDropdown
|
||||
filter={filter}
|
||||
onSetFilter={onSetFilter}
|
||||
view={view}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
<FilterButton
|
||||
onClick={() => onEditCriterion()}
|
||||
count={criteria.length}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
<FilterTags
|
||||
searchTerm={searchTerm}
|
||||
criteria={criteria}
|
||||
@@ -55,7 +68,6 @@ export const ToolbarFilterSection: React.FC<{
|
||||
onRemoveSearchTerm={onRemoveSearchTerm}
|
||||
truncateOnOverflow
|
||||
/>
|
||||
<SidebarToggleButton onClick={onToggleSidebar} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -65,28 +77,33 @@ export const ToolbarFilterSection: React.FC<{
|
||||
export const ToolbarSelectionSection: React.FC<{
|
||||
selected: number;
|
||||
onToggleSidebar: () => void;
|
||||
operations?: React.ReactNode;
|
||||
onSelectAll: () => void;
|
||||
onSelectNone: () => void;
|
||||
}> = PatchComponent(
|
||||
"ToolbarSelectionSection",
|
||||
({ selected, onToggleSidebar, onSelectAll, onSelectNone }) => {
|
||||
({ selected, onToggleSidebar, operations, onSelectAll, onSelectNone }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className="selected-items-info">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="minimal"
|
||||
onClick={() => onSelectNone()}
|
||||
title={intl.formatMessage({ id: "actions.select_none" })}
|
||||
>
|
||||
<Icon icon={faTimes} />
|
||||
</Button>
|
||||
<span>{selected} selected</span>
|
||||
<Button variant="link" onClick={() => onSelectAll()}>
|
||||
<FormattedMessage id="actions.select_all" />
|
||||
</Button>
|
||||
<SidebarToggleButton onClick={onToggleSidebar} />
|
||||
<div className="toolbar-selection-section">
|
||||
<div className="selected-items-info">
|
||||
<SidebarToggleButton onClick={onToggleSidebar} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="minimal"
|
||||
onClick={() => onSelectNone()}
|
||||
title={intl.formatMessage({ id: "actions.select_none" })}
|
||||
>
|
||||
<Icon icon={faTimes} />
|
||||
</Button>
|
||||
<span>{selected} selected</span>
|
||||
<Button variant="link" onClick={() => onSelectAll()}>
|
||||
<FormattedMessage id="actions.select_all" />
|
||||
</Button>
|
||||
</div>
|
||||
{operations}
|
||||
<div className="empty-space" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,7 +131,11 @@ export const FilteredListToolbar2: React.FC<{
|
||||
})}
|
||||
>
|
||||
{!hasSelection ? filterSection : selectionSection}
|
||||
<div className="filtered-list-toolbar-operations">{operationSection}</div>
|
||||
{!hasSelection ? (
|
||||
<div className="filtered-list-toolbar-operations">
|
||||
{operationSection}
|
||||
</div>
|
||||
) : null}
|
||||
</ButtonToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ const PageCount: React.FC<{
|
||||
useStopWheelScroll(pageInput);
|
||||
|
||||
const pageOptions = useMemo(() => {
|
||||
const maxPagesToShow = 10;
|
||||
const maxPagesToShow = 1000;
|
||||
const min = Math.max(1, currentPage - maxPagesToShow / 2);
|
||||
const max = Math.min(min + maxPagesToShow, totalPages);
|
||||
const pages = [];
|
||||
|
||||
@@ -31,6 +31,7 @@ import { AlertModal } from "../Shared/Alert";
|
||||
import cx from "classnames";
|
||||
import { TruncatedInlineText } from "../Shared/TruncatedText";
|
||||
import { OperationButton } from "../Shared/OperationButton";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const ExistingSavedFilterList: React.FC<{
|
||||
name: string;
|
||||
@@ -243,6 +244,7 @@ interface ISavedFilterListProps {
|
||||
filter: ListFilterModel;
|
||||
onSetFilter: (f: ListFilterModel) => void;
|
||||
view?: View;
|
||||
menuPortalTarget?: Element | DocumentFragment;
|
||||
}
|
||||
|
||||
export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
|
||||
@@ -841,8 +843,15 @@ export const SavedFilterDropdown: React.FC<ISavedFilterListProps> = (props) => {
|
||||
));
|
||||
SavedFilterDropdownRef.displayName = "SavedFilterDropdown";
|
||||
|
||||
const menu = (
|
||||
<Dropdown.Menu
|
||||
as={SavedFilterDropdownRef}
|
||||
className="saved-filter-list-menu"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown as={ButtonGroup}>
|
||||
<Dropdown as={ButtonGroup} className="saved-filter-dropdown">
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={
|
||||
@@ -855,10 +864,9 @@ export const SavedFilterDropdown: React.FC<ISavedFilterListProps> = (props) => {
|
||||
<Icon icon={faBookmark} />
|
||||
</Dropdown.Toggle>
|
||||
</OverlayTrigger>
|
||||
<Dropdown.Menu
|
||||
as={SavedFilterDropdownRef}
|
||||
className="saved-filter-list-menu"
|
||||
/>
|
||||
{props.menuPortalTarget
|
||||
? createPortal(menu, props.menuPortalTarget)
|
||||
: menu}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -91,6 +91,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// hide zoom slider in xs viewport
|
||||
@include media-breakpoint-down(xs) {
|
||||
.display-mode-menu .zoom-slider-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.display-mode-popover {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
@@ -1048,7 +1055,7 @@ input[type="range"].zoom-slider {
|
||||
}
|
||||
|
||||
// hide sidebar Edit Filter button on larger screens
|
||||
@include media-breakpoint-up(lg) {
|
||||
@include media-breakpoint-up(md) {
|
||||
.sidebar .edit-filter-button {
|
||||
display: none;
|
||||
}
|
||||
@@ -1064,6 +1071,7 @@ input[type="range"].zoom-slider {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0;
|
||||
row-gap: 1rem;
|
||||
|
||||
> div {
|
||||
@@ -1094,10 +1102,6 @@ input[type="range"].zoom-slider {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.selected-items-info .btn {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
// hide drop down menu items for play and create new
|
||||
// when the buttons are visible
|
||||
@include media-breakpoint-up(sm) {
|
||||
@@ -1118,7 +1122,7 @@ input[type="range"].zoom-slider {
|
||||
}
|
||||
}
|
||||
|
||||
.selected-items-info,
|
||||
.toolbar-selection-section,
|
||||
div.filter-section {
|
||||
border: 1px solid $secondary;
|
||||
border-radius: 0.25rem;
|
||||
@@ -1126,13 +1130,69 @@ input[type="range"].zoom-slider {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.sidebar-toggle-button {
|
||||
margin-left: auto;
|
||||
div.toolbar-selection-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: center;
|
||||
|
||||
.sidebar-toggle-button {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.selected-items-info {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
> div:first-child,
|
||||
> div:last-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
> div:last-child {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.scene-list-operations {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
// on smaller viewports move the operation buttons to the right
|
||||
@include media-breakpoint-down(md) {
|
||||
div.scene-list-operations {
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
order: 3;
|
||||
}
|
||||
|
||||
> div:last-child {
|
||||
flex: 0;
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// on larger viewports, move the operation buttons to the center
|
||||
@include media-breakpoint-up(lg) {
|
||||
div.toolbar-selection-section div.scene-list-operations {
|
||||
justify-content: center;
|
||||
|
||||
> .btn-group {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
div.toolbar-selection-section .empty-space {
|
||||
flex: 1;
|
||||
order: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
border-right: 1px solid $secondary;
|
||||
display: block;
|
||||
display: flex;
|
||||
margin-right: -0.5rem;
|
||||
min-width: calc($sidebar-width - 15px);
|
||||
padding-right: 10px;
|
||||
@@ -1168,21 +1228,27 @@ input[type="range"].zoom-slider {
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(xl) {
|
||||
// hide the search box in the toolbar when sidebar is shown on larger screens
|
||||
// larger screens don't overlap the sidebar
|
||||
@include media-breakpoint-up(md) {
|
||||
.sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar .search-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
// hide the search box when sidebar is hidden on smaller screens
|
||||
@include media-breakpoint-down(md) {
|
||||
.sidebar-pane.hide-sidebar .filtered-list-toolbar .search-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// hide the filter icon button when sidebar is shown on smaller screens
|
||||
@include media-breakpoint-down(md) {
|
||||
.sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar .filter-button {
|
||||
display: none;
|
||||
// hide the filter and saved filters icon buttons when sidebar is shown on smaller screens
|
||||
@include media-breakpoint-down(sm) {
|
||||
.sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar {
|
||||
.filter-button,
|
||||
.saved-filter-dropdown {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// adjust the width of the filter-tags as well
|
||||
@@ -1191,8 +1257,8 @@ input[type="range"].zoom-slider {
|
||||
}
|
||||
}
|
||||
|
||||
// move the sidebar toggle to the left on xl viewports
|
||||
@include media-breakpoint-up(xl) {
|
||||
// move the sidebar toggle to the left on larger viewports
|
||||
@include media-breakpoint-up(md) {
|
||||
.filtered-list-toolbar .filter-section {
|
||||
.sidebar-toggle-button {
|
||||
margin-left: 0;
|
||||
@@ -1242,14 +1308,18 @@ input[type="range"].zoom-slider {
|
||||
align-items: center;
|
||||
background-color: $body-bg;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
> div {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
|
||||
&.pagination-index-container {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
@@ -1258,18 +1328,55 @@ input[type="range"].zoom-slider {
|
||||
}
|
||||
|
||||
.list-results-header {
|
||||
flex-wrap: wrap-reverse;
|
||||
gap: 0.5rem;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.paginationIndex {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// move pagination info to right on medium screens
|
||||
@include media-breakpoint-down(md) {
|
||||
& > .empty-space {
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
& > div.pagination-index-container {
|
||||
justify-content: flex-end;
|
||||
order: 3;
|
||||
}
|
||||
}
|
||||
|
||||
// center the header on smaller screens
|
||||
@include media-breakpoint-down(sm) {
|
||||
& > div,
|
||||
& > div:last-child {
|
||||
& > div.pagination-index-container {
|
||||
flex-basis: 100%;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sidebar visible styling
|
||||
.sidebar-pane:not(.hide-sidebar) .list-results-header {
|
||||
// move pagination info to right on medium screens when sidebar
|
||||
@include media-breakpoint-down(lg) {
|
||||
& > .empty-space {
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
& > div.pagination-index-container {
|
||||
justify-content: flex-end;
|
||||
order: 3;
|
||||
}
|
||||
}
|
||||
|
||||
// center the header on smaller screens when sidebar is visible
|
||||
@include media-breakpoint-down(md) {
|
||||
& > div,
|
||||
& > div.pagination-index-container {
|
||||
flex-basis: 100%;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
|
||||
@@ -12,6 +12,13 @@ import * as GQL from "src/core/generated-graphql";
|
||||
import { DisplayMode } from "src/models/list-filter/types";
|
||||
import { Criterion } from "src/models/list-filter/criteria/criterion";
|
||||
|
||||
function locationEquals(
|
||||
loc1: ReturnType<typeof useLocation> | undefined,
|
||||
loc2: ReturnType<typeof useLocation>
|
||||
) {
|
||||
return loc1 && loc1.pathname === loc2.pathname && loc1.search === loc2.search;
|
||||
}
|
||||
|
||||
export function useFilterURL(
|
||||
filter: ListFilterModel,
|
||||
setFilter: React.Dispatch<React.SetStateAction<ListFilterModel>>,
|
||||
@@ -49,7 +56,7 @@ export function useFilterURL(
|
||||
useEffect(() => {
|
||||
// don't apply if active is false
|
||||
// also don't apply if location is unchanged
|
||||
if (!active || prevLocation === location) return;
|
||||
if (!active || locationEquals(prevLocation, location)) return;
|
||||
|
||||
// re-init to load default filter on empty new query params
|
||||
if (!location.search) {
|
||||
|
||||
@@ -47,6 +47,7 @@ import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage";
|
||||
import { LightboxLink } from "src/hooks/Lightbox/LightboxLink";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { ILightboxImage } from "src/hooks/Lightbox/types";
|
||||
import { goBackOrReplace } from "src/utils/history";
|
||||
|
||||
interface IProps {
|
||||
performer: GQL.PerformerDataFragment;
|
||||
@@ -330,7 +331,7 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
|
||||
return;
|
||||
}
|
||||
|
||||
history.goBack();
|
||||
goBackOrReplace(history, "/performers");
|
||||
}
|
||||
|
||||
function toggleEditing(value?: boolean) {
|
||||
|
||||
@@ -51,6 +51,7 @@ import { lazyComponent } from "src/utils/lazyComponent";
|
||||
import cx from "classnames";
|
||||
import { TruncatedText } from "src/components/Shared/TruncatedText";
|
||||
import { PatchComponent, PatchContainerComponent } from "src/patch";
|
||||
import { goBackOrReplace } from "src/utils/history";
|
||||
|
||||
const SubmitStashBoxDraft = lazyComponent(
|
||||
() => import("src/components/Dialogs/SubmitDraft")
|
||||
@@ -909,7 +910,7 @@ const SceneLoader: React.FC<RouteComponentProps<ISceneParams>> = ({
|
||||
) {
|
||||
loadScene(queueScenes[currentQueueIndex + 1].id);
|
||||
} else {
|
||||
history.goBack();
|
||||
goBackOrReplace(history, "/scenes");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,13 @@ import {
|
||||
OperationDropdownItem,
|
||||
} from "../List/ListOperationButtons";
|
||||
import { useFilteredItemList } from "../List/ItemList";
|
||||
import { Sidebar, SidebarPane, useSidebarState } from "../Shared/Sidebar";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarPane,
|
||||
SidebarPaneContent,
|
||||
SidebarStateContext,
|
||||
useSidebarState,
|
||||
} from "../Shared/Sidebar";
|
||||
import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter";
|
||||
import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter";
|
||||
import { PerformersCriterionOption } from "src/models/list-filter/criteria/performers";
|
||||
@@ -285,6 +291,7 @@ const SidebarContent: React.FC<{
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
filterHook={filterHook}
|
||||
sectionID="studios"
|
||||
/>
|
||||
)}
|
||||
<SidebarPerformersFilter
|
||||
@@ -294,6 +301,7 @@ const SidebarContent: React.FC<{
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
filterHook={filterHook}
|
||||
sectionID="performers"
|
||||
/>
|
||||
<SidebarTagsFilter
|
||||
title={<FormattedMessage id="tags" />}
|
||||
@@ -302,6 +310,7 @@ const SidebarContent: React.FC<{
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
filterHook={filterHook}
|
||||
sectionID="tags"
|
||||
/>
|
||||
<SidebarRatingFilter
|
||||
title={<FormattedMessage id="rating" />}
|
||||
@@ -309,6 +318,7 @@ const SidebarContent: React.FC<{
|
||||
option={RatingCriterionOption}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
sectionID="rating"
|
||||
/>
|
||||
<SidebarBooleanFilter
|
||||
title={<FormattedMessage id="organized" />}
|
||||
@@ -316,6 +326,7 @@ const SidebarContent: React.FC<{
|
||||
option={OrganizedCriterionOption}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
sectionID="organized"
|
||||
/>
|
||||
</ScenesFilterSidebarSections>
|
||||
|
||||
@@ -355,7 +366,7 @@ const SceneListOperations: React.FC<{
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="scene-list-operations">
|
||||
<ButtonGroup>
|
||||
{!!items && (
|
||||
<Button
|
||||
@@ -396,7 +407,10 @@ const SceneListOperations: React.FC<{
|
||||
</>
|
||||
)}
|
||||
|
||||
<OperationDropdown className="scene-list-operations">
|
||||
<OperationDropdown
|
||||
className="scene-list-operations"
|
||||
menuPortalTarget={document.body}
|
||||
>
|
||||
{operations.map((o) => {
|
||||
if (o.isDisplayed && !o.isDisplayed()) {
|
||||
return null;
|
||||
@@ -439,6 +453,8 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
showSidebar,
|
||||
setShowSidebar,
|
||||
loading: sidebarStateLoading,
|
||||
sectionOpen,
|
||||
setSectionOpen,
|
||||
} = useSidebarState(view);
|
||||
|
||||
const { filterState, queryResult, modalState, listSelect, showEditFilter } =
|
||||
@@ -518,7 +534,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
|
||||
const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]);
|
||||
|
||||
const playRandom = usePlayRandom(filter, totalCount);
|
||||
const playRandom = usePlayRandom(effectiveFilter, totalCount);
|
||||
const playSelected = usePlaySelected(selectedIds);
|
||||
const playFirst = usePlayFirst();
|
||||
|
||||
@@ -666,6 +682,18 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
// render
|
||||
if (filterLoading || sidebarStateLoading) return null;
|
||||
|
||||
const operations = (
|
||||
<SceneListOperations
|
||||
items={items.length}
|
||||
hasSelection={hasSelection}
|
||||
operations={otherOperations}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onPlay={onPlay}
|
||||
onCreateNew={onCreateNew}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<TaggerContext>
|
||||
<div
|
||||
@@ -675,94 +703,90 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
>
|
||||
{modal}
|
||||
|
||||
<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>
|
||||
<div>
|
||||
<FilteredListToolbar2
|
||||
className="scene-list-toolbar"
|
||||
hasSelection={hasSelection}
|
||||
filterSection={
|
||||
<ToolbarFilterSection
|
||||
filter={filter}
|
||||
onSetFilter={setFilter}
|
||||
onToggleSidebar={() => setShowSidebar(!showSidebar)}
|
||||
onEditCriterion={(c) =>
|
||||
showEditFilter(c?.criterionOption.type)
|
||||
}
|
||||
onRemoveCriterion={removeCriterion}
|
||||
onRemoveAllCriterion={() => clearAllCriteria(true)}
|
||||
onEditSearchTerm={() => {
|
||||
setShowSidebar(true);
|
||||
setSearchFocus(true);
|
||||
}}
|
||||
onRemoveSearchTerm={() => setFilter(filter.clearSearchTerm())}
|
||||
/>
|
||||
}
|
||||
selectionSection={
|
||||
<ToolbarSelectionSection
|
||||
selected={selectedIds.size}
|
||||
onToggleSidebar={() => setShowSidebar(!showSidebar)}
|
||||
onSelectAll={() => onSelectAll()}
|
||||
onSelectNone={() => onSelectNone()}
|
||||
/>
|
||||
}
|
||||
operationSection={
|
||||
<SceneListOperations
|
||||
items={items.length}
|
||||
hasSelection={hasSelection}
|
||||
operations={otherOperations}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onPlay={onPlay}
|
||||
onCreateNew={onCreateNew}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<ListResultsHeader
|
||||
loading={cachedResult.loading}
|
||||
filter={filter}
|
||||
totalCount={totalCount}
|
||||
metadataByline={metadataByline}
|
||||
onChangeFilter={(newFilter) => setFilter(newFilter)}
|
||||
/>
|
||||
|
||||
<LoadedContent loading={result.loading} error={result.error}>
|
||||
<SceneList
|
||||
filter={effectiveFilter}
|
||||
scenes={items}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
fromGroupId={fromGroupId}
|
||||
<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>
|
||||
<FilteredListToolbar2
|
||||
className="scene-list-toolbar"
|
||||
hasSelection={hasSelection}
|
||||
filterSection={
|
||||
<ToolbarFilterSection
|
||||
filter={filter}
|
||||
onSetFilter={setFilter}
|
||||
onToggleSidebar={() => setShowSidebar(!showSidebar)}
|
||||
onEditCriterion={(c) =>
|
||||
showEditFilter(c?.criterionOption.type)
|
||||
}
|
||||
onRemoveCriterion={removeCriterion}
|
||||
onRemoveAllCriterion={() => clearAllCriteria(true)}
|
||||
onEditSearchTerm={() => {
|
||||
setShowSidebar(true);
|
||||
setSearchFocus(true);
|
||||
}}
|
||||
onRemoveSearchTerm={() =>
|
||||
setFilter(filter.clearSearchTerm())
|
||||
}
|
||||
view={view}
|
||||
/>
|
||||
}
|
||||
selectionSection={
|
||||
<ToolbarSelectionSection
|
||||
selected={selectedIds.size}
|
||||
onToggleSidebar={() => setShowSidebar(!showSidebar)}
|
||||
onSelectAll={() => onSelectAll()}
|
||||
onSelectNone={() => onSelectNone()}
|
||||
operations={operations}
|
||||
/>
|
||||
}
|
||||
operationSection={operations}
|
||||
/>
|
||||
</LoadedContent>
|
||||
|
||||
{totalCount > filter.itemsPerPage && (
|
||||
<div className="pagination-footer">
|
||||
<Pagination
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
totalItems={totalCount}
|
||||
metadataByline={metadataByline}
|
||||
onChangePage={setPage}
|
||||
pagePopupPlacement="top"
|
||||
<ListResultsHeader
|
||||
loading={cachedResult.loading}
|
||||
filter={filter}
|
||||
totalCount={totalCount}
|
||||
metadataByline={metadataByline}
|
||||
onChangeFilter={(newFilter) => setFilter(newFilter)}
|
||||
/>
|
||||
|
||||
<LoadedContent loading={result.loading} error={result.error}>
|
||||
<SceneList
|
||||
filter={effectiveFilter}
|
||||
scenes={items}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
fromGroupId={fromGroupId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SidebarPane>
|
||||
</LoadedContent>
|
||||
|
||||
{totalCount > filter.itemsPerPage && (
|
||||
<div className="pagination-footer">
|
||||
<Pagination
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
totalItems={totalCount}
|
||||
metadataByline={metadataByline}
|
||||
onChangePage={setPage}
|
||||
pagePopupPlacement="top"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SidebarPaneContent>
|
||||
</SidebarPane>
|
||||
</SidebarStateContext.Provider>
|
||||
</div>
|
||||
</TaggerContext>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
faChevronRight,
|
||||
faChevronUp,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Button, Collapse, CollapseProps } from "react-bootstrap";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
@@ -12,22 +12,27 @@ interface IProps {
|
||||
text: React.ReactNode;
|
||||
collapseProps?: Partial<CollapseProps>;
|
||||
outsideCollapse?: React.ReactNode;
|
||||
onOpen?: () => void;
|
||||
onOpenChanged?: (o: boolean) => void;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
export const CollapseButton: React.FC<React.PropsWithChildren<IProps>> = (
|
||||
props: React.PropsWithChildren<IProps>
|
||||
) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(props.open ?? false);
|
||||
|
||||
function toggleOpen() {
|
||||
const nv = !open;
|
||||
setOpen(nv);
|
||||
if (props.onOpen && nv) {
|
||||
props.onOpen();
|
||||
}
|
||||
props.onOpenChanged?.(nv);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (props.open !== undefined) {
|
||||
setOpen(props.open);
|
||||
}
|
||||
}, [props.open]);
|
||||
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<div className="collapse-header">
|
||||
|
||||
@@ -15,8 +15,12 @@ import { Button, CollapseProps } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
import { Icon } from "./Icon";
|
||||
import { faSliders } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
const fixedSidebarMediaQuery = "only screen and (max-width: 1199px)";
|
||||
export type SidebarSectionStates = Record<string, boolean>;
|
||||
|
||||
// this needs to correspond to the CSS media query that overlaps the sidebar over content
|
||||
const fixedSidebarMediaQuery = "only screen and (max-width: 767px)";
|
||||
|
||||
export const Sidebar: React.FC<
|
||||
PropsWithChildren<{
|
||||
@@ -56,14 +60,39 @@ export const SidebarPane: React.FC<
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarPaneContent: React.FC = ({ children }) => {
|
||||
return <div className="sidebar-pane-content">{children}</div>;
|
||||
};
|
||||
|
||||
interface IContext {
|
||||
sectionOpen: SidebarSectionStates;
|
||||
setSectionOpen: (section: string, open: boolean) => void;
|
||||
}
|
||||
|
||||
export const SidebarStateContext = React.createContext<IContext | null>(null);
|
||||
|
||||
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;
|
||||
}>
|
||||
> = ({ className = "", text, outsideCollapse, onOpen, children }) => {
|
||||
> = ({ className = "", text, outsideCollapse, sectionID = "", children }) => {
|
||||
// this is optional
|
||||
const contextState = React.useContext(SidebarStateContext);
|
||||
const openState =
|
||||
!contextState || !sectionID
|
||||
? undefined
|
||||
: contextState.sectionOpen[sectionID] ?? undefined;
|
||||
|
||||
function onOpenInternal(open: boolean) {
|
||||
if (contextState && sectionID) {
|
||||
contextState.setSectionOpen(sectionID, open);
|
||||
}
|
||||
}
|
||||
|
||||
const collapseProps: Partial<CollapseProps> = {
|
||||
mountOnEnter: true,
|
||||
unmountOnExit: true,
|
||||
@@ -74,7 +103,8 @@ export const SidebarSection: React.FC<
|
||||
collapseProps={collapseProps}
|
||||
text={text}
|
||||
outsideCollapse={outsideCollapse}
|
||||
onOpen={onOpen}
|
||||
onOpenChanged={onOpenInternal}
|
||||
open={openState}
|
||||
>
|
||||
{children}
|
||||
</CollapseButton>
|
||||
@@ -87,7 +117,7 @@ export const SidebarToggleButton: React.FC<{
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<Button
|
||||
className="minimal sidebar-toggle-button ignore-sidebar-outside-click"
|
||||
className="sidebar-toggle-button ignore-sidebar-outside-click"
|
||||
variant="secondary"
|
||||
onClick={onClick}
|
||||
title={intl.formatMessage({ id: "actions.sidebar.toggle" })}
|
||||
@@ -105,6 +135,7 @@ export function defaultShowSidebar() {
|
||||
export function useSidebarState(view?: View) {
|
||||
const [interfaceLocalForage, setInterfaceLocalForage] =
|
||||
useInterfaceLocalForage();
|
||||
const history = useHistory();
|
||||
|
||||
const { data: interfaceLocalForageData, loading } = interfaceLocalForage;
|
||||
|
||||
@@ -113,6 +144,7 @@ export function useSidebarState(view?: View) {
|
||||
}, [view, interfaceLocalForageData]);
|
||||
|
||||
const [showSidebar, setShowSidebar] = useState<boolean>();
|
||||
const [sectionOpen, setSectionOpen] = useState<SidebarSectionStates>();
|
||||
|
||||
// set initial state once loading is done
|
||||
useEffect(() => {
|
||||
@@ -127,7 +159,17 @@ export function useSidebarState(view?: View) {
|
||||
|
||||
// only show sidebar by default on large screens
|
||||
setShowSidebar(!!viewConfig.showSidebar && defaultShowSidebar());
|
||||
}, [view, loading, showSidebar, viewConfig.showSidebar]);
|
||||
setSectionOpen(
|
||||
(history.location.state as { sectionOpen?: SidebarSectionStates })
|
||||
?.sectionOpen || {}
|
||||
);
|
||||
}, [
|
||||
view,
|
||||
loading,
|
||||
showSidebar,
|
||||
viewConfig.showSidebar,
|
||||
history.location.state,
|
||||
]);
|
||||
|
||||
const onSetShowSidebar = useCallback(
|
||||
(show: boolean | ((prevState: boolean | undefined) => boolean)) => {
|
||||
@@ -149,9 +191,28 @@ export function useSidebarState(view?: View) {
|
||||
[showSidebar, setInterfaceLocalForage, view, viewConfig]
|
||||
);
|
||||
|
||||
const onSetSectionOpen = useCallback(
|
||||
(section: string, open: boolean) => {
|
||||
const newSectionOpen = { ...sectionOpen, [section]: open };
|
||||
setSectionOpen(newSectionOpen);
|
||||
if (view === undefined) return;
|
||||
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: {
|
||||
...(history.location.state as {}),
|
||||
sectionOpen: newSectionOpen,
|
||||
},
|
||||
});
|
||||
},
|
||||
[sectionOpen, view, history]
|
||||
);
|
||||
|
||||
return {
|
||||
showSidebar: showSidebar ?? defaultShowSidebar(),
|
||||
sectionOpen: sectionOpen || {},
|
||||
setShowSidebar: onSetShowSidebar,
|
||||
setSectionOpen: onSetSectionOpen,
|
||||
loading: showSidebar === undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -805,7 +805,7 @@ button.btn.favorite-button {
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(xl) {
|
||||
@include media-breakpoint-up(md) {
|
||||
transition: margin-left 0.1s;
|
||||
|
||||
&:not(.hide-sidebar) {
|
||||
@@ -910,12 +910,12 @@ $sticky-header-height: calc(50px + 3.3rem);
|
||||
}
|
||||
|
||||
// on smaller viewports we want the sidebar to overlap content
|
||||
@include media-breakpoint-down(lg) {
|
||||
@include media-breakpoint-down(sm) {
|
||||
.sidebar-pane:not(.hide-sidebar) .sidebar {
|
||||
margin-right: -$sidebar-width;
|
||||
}
|
||||
|
||||
.sidebar-pane > :nth-child(2) {
|
||||
.sidebar-pane > .sidebar-pane-content {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -935,7 +935,7 @@ $sticky-header-height: calc(50px + 3.3rem);
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@include media-breakpoint-up(xl) {
|
||||
@include media-breakpoint-up(md) {
|
||||
.sidebar-pane:not(.hide-sidebar) {
|
||||
> :nth-child(2) {
|
||||
margin-left: 0;
|
||||
|
||||
@@ -47,6 +47,7 @@ import { FavoriteIcon } from "src/components/Shared/FavoriteIcon";
|
||||
import { ExternalLinkButtons } from "src/components/Shared/ExternalLinksButton";
|
||||
import { AliasList } from "src/components/Shared/DetailsPage/AliasList";
|
||||
import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage";
|
||||
import { goBackOrReplace } from "src/utils/history";
|
||||
|
||||
interface IProps {
|
||||
studio: GQL.StudioDataFragment;
|
||||
@@ -378,7 +379,7 @@ const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
history.goBack();
|
||||
goBackOrReplace(history, "/studios");
|
||||
}
|
||||
|
||||
function renderDeleteAlert() {
|
||||
|
||||
@@ -49,6 +49,7 @@ import { ExpandCollapseButton } from "src/components/Shared/CollapseButton";
|
||||
import { FavoriteIcon } from "src/components/Shared/FavoriteIcon";
|
||||
import { AliasList } from "src/components/Shared/DetailsPage/AliasList";
|
||||
import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage";
|
||||
import { goBackOrReplace } from "src/utils/history";
|
||||
|
||||
interface IProps {
|
||||
tag: GQL.TagDataFragment;
|
||||
@@ -420,7 +421,7 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
history.goBack();
|
||||
goBackOrReplace(history, "/tags");
|
||||
}
|
||||
|
||||
function renderDeleteAlert() {
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
## Library
|
||||
|
||||
This section allows you to add and remove directories from your library list. Files in these directories will be included when scanning. Files that are outside of these directories will be removed when running the Clean task.
|
||||
This section enables you to add or remove directories that will be discoverable by Stash. The directories you add will be utilized for scanning new files and for updating their locations in Stash database.
|
||||
|
||||
You can configure these directories to apply specifically to:
|
||||
|
||||
- **Videos**
|
||||
- **Images**
|
||||
- **Both**
|
||||
|
||||
> **⚠️ Note:** Don't forget to click `Save` after updating these directories!
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export const SceneIsMissingCriterionOption = new IsMissingCriterionOption(
|
||||
"date",
|
||||
"galleries",
|
||||
"studio",
|
||||
"movie",
|
||||
"group",
|
||||
"performers",
|
||||
"tags",
|
||||
"stash_id",
|
||||
|
||||
@@ -183,7 +183,7 @@ export class ListFilterModel {
|
||||
ret.disp = Number.parseInt(params.disp, 10);
|
||||
}
|
||||
if (params.q) {
|
||||
ret.q = params.q.trim();
|
||||
ret.q = params.q;
|
||||
}
|
||||
if (params.p) {
|
||||
ret.p = Number.parseInt(params.p, 10);
|
||||
|
||||
11
ui/v2.5/src/utils/history.ts
Normal file
11
ui/v2.5/src/utils/history.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
type History = ReturnType<typeof useHistory>;
|
||||
|
||||
export function goBackOrReplace(history: History, defaultPath: string) {
|
||||
if (history.length > 1) {
|
||||
history.goBack();
|
||||
} else {
|
||||
history.replace(defaultPath);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user