Merge pull request #6242 from stashapp/releases/0.29.3

Merge 0.29.3 to master
This commit is contained in:
WithoutPants 2025-11-06 17:20:22 +11:00 committed by GitHub
commit e92a0cb126
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 766 additions and 256 deletions

View file

@ -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
}
}

View file

@ -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 {

View file

@ -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"`

View file

@ -126,6 +126,7 @@ type mappedSceneScraperConfig struct {
Performers mappedPerformerScraperConfig `yaml:"Performers"`
Studio mappedConfig `yaml:"Studio"`
Movies mappedConfig `yaml:"Movies"`
Groups mappedConfig `yaml:"Groups"`
}
type _mappedSceneScraperConfig mappedSceneScraperConfig
@ -134,6 +135,7 @@ const (
mappedScraperConfigScenePerformers = "Performers"
mappedScraperConfigSceneStudio = "Studio"
mappedScraperConfigSceneMovies = "Movies"
mappedScraperConfigSceneGroups = "Groups"
)
func (s *mappedSceneScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
@ -151,11 +153,13 @@ func (s *mappedSceneScraperConfig) UnmarshalYAML(unmarshal func(interface{}) err
thisMap[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers]
thisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio]
thisMap[mappedScraperConfigSceneMovies] = parentMap[mappedScraperConfigSceneMovies]
thisMap[mappedScraperConfigSceneGroups] = parentMap[mappedScraperConfigSceneGroups]
delete(parentMap, mappedScraperConfigSceneTags)
delete(parentMap, mappedScraperConfigScenePerformers)
delete(parentMap, mappedScraperConfigSceneStudio)
delete(parentMap, mappedScraperConfigSceneMovies)
delete(parentMap, mappedScraperConfigSceneGroups)
// re-unmarshal the sub-fields
yml, err := yaml.Marshal(thisMap)
@ -873,50 +877,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
@ -1008,6 +1017,7 @@ func (s mappedScraper) processSceneRelationships(ctx context.Context, q mappedQu
sceneTagsMap := sceneScraperConfig.Tags
sceneStudioMap := sceneScraperConfig.Studio
sceneMoviesMap := sceneScraperConfig.Movies
sceneGroupsMap := sceneScraperConfig.Groups
ret.Performers = s.processPerformers(ctx, scenePerformersMap, q)
@ -1034,7 +1044,12 @@ func (s mappedScraper) processSceneRelationships(ctx context.Context, q mappedQu
ret.Movies = processRelationships[models.ScrapedMovie](ctx, s, sceneMoviesMap, q)
}
return len(ret.Performers) > 0 || len(ret.Tags) > 0 || ret.Studio != nil || len(ret.Movies) > 0
if sceneGroupsMap != nil {
logger.Debug(`Processing scene groups:`)
ret.Groups = processRelationships[models.ScrapedGroup](ctx, s, sceneGroupsMap, q)
}
return len(ret.Performers) > 0 || len(ret.Tags) > 0 || ret.Studio != nil || len(ret.Movies) > 0 || len(ret.Groups) > 0
}
func (s mappedScraper) processPerformers(ctx context.Context, performersMap mappedPerformerScraperConfig, q mappedQuery) []*models.ScrapedPerformer {

View file

@ -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)

View file

@ -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":

View file

@ -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");
}
}

View file

@ -114,6 +114,7 @@ export const AddSubGroupsDialog: React.FC<IListOperationProps> = (
onUpdate={(input) => setEntries(input)}
excludeIDs={excludeIDs}
filterHook={filterHook}
menuPortalTarget={document.body}
/>
</Form>
</ModalComponent>

View file

@ -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) {

View file

@ -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,

View file

@ -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");
}
}

View file

@ -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>

View file

@ -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}
/>
</>
);

View file

@ -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}

View file

@ -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;

View file

@ -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>
</>

View file

@ -276,6 +276,8 @@ export const SidebarListFilter: React.FC<{
preCandidates?: React.ReactNode;
postCandidates?: React.ReactNode;
onOpen?: () => void;
// used to store open/closed state in SidebarStateContext
sectionID?: string;
}> = ({
title,
selected,
@ -292,6 +294,7 @@ export const SidebarListFilter: React.FC<{
preSelected,
postSelected,
onOpen,
sectionID,
}) => {
// TODO - sort items?
@ -325,6 +328,7 @@ export const SidebarListFilter: React.FC<{
<SidebarSection
className="sidebar-list-filter"
text={title}
sectionID={sectionID}
outsideCollapse={
<>
{preSelected ? <div className="extra">{preSelected}</div> : null}

View file

@ -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;

View file

@ -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;

View file

@ -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>
);
};

View file

@ -1,6 +1,6 @@
import React from "react";
import { ListFilterModel } from "src/models/list-filter/filter";
import { PaginationIndex } from "../List/Pagination";
import { Pagination, PaginationIndex } from "../List/Pagination";
import { ButtonToolbar } from "react-bootstrap";
import { ListViewOptions } from "../List/ListViewOptions";
import { PageSizeSelector, SortBySelect } from "../List/ListFilter";
@ -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,22 @@ export const ListResultsHeader: React.FC<{
onSetZoom={(zoom) => onChangeFilter(filter.setZoom(zoom))}
/>
</div>
<div className="pagination-index-container">
<Pagination
currentPage={filter.currentPage}
itemsPerPage={filter.itemsPerPage}
totalItems={totalCount}
onChangePage={(page) => onChangeFilter(filter.changePage(page))}
/>
<PaginationIndex
loading={loading}
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
metadataByline={metadataByline}
/>
</div>
<div className="empty-space"></div>
</ButtonToolbar>
);
};

View file

@ -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>
);
};

View file

@ -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 = [];

View file

@ -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>
);
};

View file

@ -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;
@ -1239,17 +1305,21 @@ input[type="range"].zoom-slider {
}
.list-results-header {
align-items: center;
align-items: flex-start;
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;
@ -1257,19 +1327,72 @@ input[type="range"].zoom-slider {
}
}
.list-results-header {
flex-wrap: wrap-reverse;
.list-results-header .pagination-index-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
.pagination {
// hidden by default. Can be shown via css override if needed
display: none;
margin: 0;
}
}
.list-results-header {
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 {
align-items: 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;
}
& > div.pagination-index-container {
align-items: center;
}
}
}
// 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;

View file

@ -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) {

View file

@ -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) {

View file

@ -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");
}
}

View file

@ -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>
);

View file

@ -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">

View file

@ -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,53 @@ 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,
onOpen,
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);
}
}
useEffect(() => {
if (openState && onOpen) {
onOpen();
}
}, [openState, onOpen]);
const collapseProps: Partial<CollapseProps> = {
mountOnEnter: true,
unmountOnExit: true,
@ -74,7 +117,8 @@ export const SidebarSection: React.FC<
collapseProps={collapseProps}
text={text}
outsideCollapse={outsideCollapse}
onOpen={onOpen}
onOpenChanged={onOpenInternal}
open={openState}
>
{children}
</CollapseButton>
@ -87,7 +131,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 +149,7 @@ export function defaultShowSidebar() {
export function useSidebarState(view?: View) {
const [interfaceLocalForage, setInterfaceLocalForage] =
useInterfaceLocalForage();
const history = useHistory();
const { data: interfaceLocalForageData, loading } = interfaceLocalForage;
@ -113,6 +158,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 +173,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 +205,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,
};
}

View file

@ -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;

View file

@ -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() {

View file

@ -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() {

View file

@ -5,6 +5,11 @@
### 🎨 Improvements
* **[0.29.2]** Returned saved filters button to the top toolbar in the Scene list. ([#6215](https://github.com/stashapp/stash/pull/6215))
* **[0.29.2]** Top pagination can now be optionally shown in the scene list with [custom css](https://github.com/stashapp/stash/pull/6234#issue-3593190476). ([#6234](https://github.com/stashapp/stash/pull/6234))
* **[0.29.2]** Restyled the scene list toolbar based on user feedback. ([#6215](https://github.com/stashapp/stash/pull/6215))
* **[0.29.2]** Sidebar section collapsed state is now saved in the browser history. ([#6217](https://github.com/stashapp/stash/pull/6217))
* **[0.29.2]** Increased the number of pages in pagination dropdown to 1000. ([#6207](https://github.com/stashapp/stash/pull/6207))
* Revamped the scene and marker wall views. ([#5816](https://github.com/stashapp/stash/pull/5816))
* Added zoom functionality to wall views. ([#6011](https://github.com/stashapp/stash/pull/6011))
* Added search term field to the Edit Filter dialog. ([#6082](https://github.com/stashapp/stash/pull/6082))
@ -28,6 +33,16 @@
* Include IP address in login errors in log. ([#5760](https://github.com/stashapp/stash/pull/5760))
### 🐛 Bug fixes
* **[0.29.3]** Fixed sidebar filter contents not loading. ([#6240](https://github.com/stashapp/stash/pull/6240))
* **[0.29.2]** Fixed Play Random not playing from the current filtered scenes on scene list sub-pages. ([#6202](https://github.com/stashapp/stash/pull/6202))
* **[0.29.2]** Fixed infinite loop in Group Sub-Groups panel. ([#6212](https://github.com/stashapp/stash/pull/6212))
* **[0.29.2]** Page no longer scrolls when selecting criterion for the first time in the Edit Filter dialog. ([#6205](https://github.com/stashapp/stash/pull/6205))
* **[0.29.2]** Zoom slider is no longer shown on mobile devices. ([#6206](https://github.com/stashapp/stash/pull/6206))
* **[0.29.2]** Fixed trailing space sometimes being trimmed from query string when querying. ([#6211](https://github.com/stashapp/stash/pull/6211))
* **[0.29.2]** Page now redirects to list page when deleting an object in a new browser tab. ([#6203](https://github.com/stashapp/stash/pull/6203))
* **[0.29.2]** Related groups can now be scraped when scraping a scene. ([#6228](https://github.com/stashapp/stash/pull/6228))
* **[0.29.2]** Fixed panic when a scraper configuration contains an unknown field. ([#6220](https://github.com/stashapp/stash/pull/6220))
* **[0.29.2]** Fixed panic when using `stash_box_index` input in scrape API calls. ([#6201](https://github.com/stashapp/stash/pull/6201))
* **[0.29.1]** Fixed password with special characters not allowing login. ([#6163](https://github.com/stashapp/stash/pull/6163))
* **[0.29.1]** Fixed layout issues using column direction for image wall. ([#6168](https://github.com/stashapp/stash/pull/6168))
* **[0.29.1]** Fixed layout issues for scene list table. ([#6169](https://github.com/stashapp/stash/pull/6169))

View file

@ -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!

View file

@ -314,6 +314,86 @@
"blobs_path": {
"description": "Къде във файловата система да се пазят бинарни данни. Позва се само ако се ползва Файлова система за блоб пазене. ВНИМАНИЕ: промяната ще изисква ръчно местене на съществуващи данни."
}
},
"ui": {
"custom_locales": {
"option_label": "Персонализирана локализация е активирана"
},
"delete_options": {
"description": "Настройки по подразбиране при триене на картини, галерий и сцени.",
"heading": "Изтриване на настройки",
"options": {
"delete_file": "Изтриване на файлове по подразбиране",
"delete_generated_supporting_files": "Изтриване на генерираните поддържащи файлове по подразбиране"
}
},
"desktop_integration": {
"desktop_integration": "Десктоп Интеграция",
"notifications_enabled": "Включване на известяването",
"send_desktop_notifications_for_events": "Изпащане на десктоп известявания за събития",
"skip_opening_browser": "Пропускане на отваряне на браузер",
"skip_opening_browser_on_startup": "Пропускане на автоматично отваряне на броузер по време на стартиране"
},
"detail": {
"compact_expanded_details": {
"description": "Когато е включена, тази настройка ще предосвати разширени детайли запавайки компактна презентация",
"heading": "Компактни разширени детайли"
},
"enable_background_image": {
"description": "Покажи фонова картина на станицата с детайли.",
"heading": "Включи фонова картина"
},
"heading": "Станица с детайли",
"show_all_details": {
"description": "Когато е включена, всичкото съдържание ще бъде показано по подразбиране и всеки детайл ще бъде в собствена колона",
"heading": "Покажи всички детайли"
}
},
"editing": {
"disable_dropdown_create": {
"description": "Премахни възможноста да се създават нови обекти от падащият селектор",
"heading": "Изключи падащо създаване"
},
"heading": "Редактиране",
"max_options_shown": {
"label": "Максимален брой неща който се показват в падащ селектор"
},
"rating_system": {
"star_precision": {
"label": "Точност на звездния рейтинг",
"options": {
"full": "Цели",
"half": "Половинки",
"quarter": "Четвъртинки",
"tenth": "Десетици"
}
},
"type": {
"label": "Тип система за рейтинг",
"options": {
"decimal": "Десетична",
"stars": "Звезди"
}
}
}
},
"funscript_offset": {
"description": "Време за разминаване в милисекунди за пускане на интерактивни скриптове.",
"heading": "Funscript разминаване (ms)"
},
"handy_connection": {
"connect": "Свързване",
"server_offset": {
"heading": "Сървърно разминаване"
},
"status": {
"heading": "Статус на връзка с Handy"
},
"sync": "Синхронизиране"
},
"handy_connection_key": {
"description": "Handy connection key за ползване със интерактивни сцени. Слагането на този ключ ще позволи на Stash да сподели иформация за текущата сцена със handyfeeling.com"
}
}
}
}

View file

@ -149,7 +149,8 @@
},
"show_results": "Resultaat tonen",
"show_count_results": "{count} resultaten tonen",
"play": "Afspelen"
"play": "Afspelen",
"load_filter": "Laad filter"
},
"actions_name": "Acties",
"age": "Leeftijd",
@ -498,7 +499,8 @@
"source": "Bron",
"source_options": "{source} Opties",
"sources": "Bronnen",
"strategy": "Strategie"
"strategy": "Strategie",
"skip_multiple_matches": "Sla overeenkomsten met meer dan één resultaat over"
},
"import_from_exported_json": "Import van geëxporteerde JSON in de map metadata. Maakt de bestaande database leeg.",
"incremental_import": "Incrementele import uit een meegeleverde export zip-bestand.",
@ -528,7 +530,13 @@
"anonymising_database": "Anonimiseer database",
"anonymise_database": "Maakt een kopie van de database naar de backups-map, waarbij alle gevoelige gegevens anoniem worden gemaakt. Deze kan vervolgens aan anderen worden verstrekt voor probleemoplossing en debugging. De originele database wordt niet gewijzigd. De geanonimiseerde database gebruikt de bestandsnaamindeling {filename_format}.",
"anonymise_and_download": "Maakt een geanonimiseerde kopie van de database en downloadt het resulterende bestand.",
"generate_clip_previews_during_scan": "Genereer voorbeelden van Afbeelingsfragmenten"
"generate_clip_previews_during_scan": "Genereer voorbeelden van Afbeelingsfragmenten",
"migrate_blobs": {
"delete_old": "Verwijder oude gegevens"
},
"migrate_scene_screenshots": {
"delete_files": "Verwijder screenshotbestanden"
}
},
"tools": {
"scene_duplicate_checker": "Scène Duplicator Checker",
@ -547,7 +555,9 @@
"whitespace_chars": "WhiteSpace-tekens",
"whitespace_chars_desc": "Deze tekens worden vervangen door witruimte in de titel"
},
"scene_tools": "Scene gereedschap"
"scene_tools": "Scene gereedschap",
"graphql_playground": "GraphQL speeltuin",
"heading": "Hulpmiddelen"
},
"ui": {
"basic_settings": "Basis instellingen",
@ -576,7 +586,15 @@
"description": "Verwijder de mogelijkheid om nieuwe objecten te maken uit de dropdown menu",
"heading": "Schakel het maken van dropdowns uit"
},
"heading": "Aanpassen"
"heading": "Aanpassen",
"rating_system": {
"type": {
"options": {
"decimal": "Decimaal",
"stars": "Sterren"
}
}
}
},
"funscript_offset": {
"description": "Time Offset in milliseconden voor het afspelen van interactieve scripts.",
@ -654,7 +672,9 @@
"continue_playlist_default": {
"description": "Speel de volgende scène in de wachtrij wanneer video is voltooid",
"heading": "Ga Standaard door met de afspeellijst"
}
},
"always_start_from_beginning": "Start video altijd vanaf het begin",
"enable_chromecast": "Chromecast inschakelen"
}
},
"scene_wall": {
@ -672,7 +692,28 @@
"description": "Diavoorstelling is beschikbaar in galerijen in de muurweergavemodus",
"heading": "Diavoorstellingsvertraging (in seconden)"
},
"title": "Gebruikers interface"
"title": "Gebruikers interface",
"custom_javascript": {
"heading": "Aangepaste JavaScript",
"option_label": "Aangepaste JavaScript ingeschakeld"
},
"custom_locales": {
"heading": "Aangepaste lokalisatie",
"option_label": "Aangepaste lokalisatie ingeschakeld"
},
"detail": {
"enable_background_image": {
"description": "Achtergrondfoto op detailscherm weergeven.",
"heading": "Achtergrondfoto inschakelen"
},
"heading": "Detailscherm",
"show_all_details": {
"heading": "Alle details weergeven"
}
},
"image_wall": {
"margin": "Marge (pixels)"
}
},
"advanced_mode": "Geavanceerde modus"
},

View file

@ -149,7 +149,8 @@
"play": "Oynat",
"show_results": "Sonuçları göster",
"show_count_results": "{count} sonucu göster",
"load": "Yükle"
"load": "Yükle",
"load_filter": "Filtre yükle"
},
"actions_name": "Eylemler",
"age": "Yaş",
@ -1021,7 +1022,7 @@
"include_sub_tags": "Alt etiketleri dahil et",
"instagram": "Instagram",
"interactive": "Etkileşimli",
"interactive_speed": "Etkileşim hızı",
"interactive_speed": "Etkileşimli Hız",
"isMissing": "Eksik",
"library": "Kütüphane",
"loading": {
@ -1036,7 +1037,7 @@
"checksum": "Sağlama Toplamı (checksum)",
"downloaded_from": "İndirildiği Yer",
"hash": "Dosya İmzası (Hash)",
"interactive_speed": "Etkileşim hızı",
"interactive_speed": "Etkileşimli Hız",
"performer_card": {
"age": "{age} {years_old}",
"age_context": "Bu sahnede {age} {years_old}"
@ -1090,7 +1091,8 @@
"name": "Filtre",
"saved_filters": "Kaydedilmiş filtreler",
"update_filter": "Filtreyi Güncelle",
"edit_filter": "Filtreyi Düzenle"
"edit_filter": "Filtreyi Düzenle",
"search_term": "Arama terimi"
},
"seconds": "Saniye",
"settings": "Ayarlar",
@ -1135,7 +1137,7 @@
},
"paths": {
"database_filename_empty_for_default": "veritabanı adı (varsayılan için boş bırakın)",
"description": "Sırada porno koleksiyonunuzun hangi dizinde olduğunun, stash veritabanının ve oluşturulan ek dosyaların nereye kaydedileceğinin belirlenmesi var. Bu ayarları sonradan değiştirebilirsiniz.",
"description": "Sırada, porno koleksiyonunuzun nerede bulunacağını ve Stash veritabanının, oluşturulan dosyaların ve önbellek dosyalarının nerede depolanacağını belirlememiz gerekiyor. Bu ayarlar daha sonra gerekirse değiştirilebilir.",
"path_to_generated_directory_empty_for_default": "oluşturulan ek dosyalar için dizin konumu (varsayılan için boş bırakın)",
"set_up_your_paths": "Yollarınızı ayarlayın",
"stash_alert": "Herhangi bir kütüphane konumu seçilmedi. Hiçbir medya Stash'e taranamayacak. Emin misiniz?",

View file

@ -32,7 +32,7 @@ export const SceneIsMissingCriterionOption = new IsMissingCriterionOption(
"date",
"galleries",
"studio",
"movie",
"group",
"performers",
"tags",
"stash_id",

View file

@ -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);

View 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);
}
}