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 // 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 return nil, err
} }
default: default:
@ -245,7 +245,7 @@ func (r *queryResolver) ScrapeMultiScenes(ctx context.Context, source scraper.So
// just flatten the slice and pass it in // just flatten the slice and pass it in
flat := sliceutil.Flatten(ret) 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 return nil, err
} }
@ -335,7 +335,7 @@ func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.S
if len(ret) > 0 { if len(ret) > 0 {
if err := r.withReadTxn(ctx, func(ctx context.Context) error { if err := r.withReadTxn(ctx, func(ctx context.Context) error {
for _, studio := range ret { 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 return err
} }
} }

View file

@ -32,6 +32,7 @@ func (t *GenerateCoverTask) Start(ctx context.Context) {
return t.Scene.LoadPrimaryFile(ctx, r.File) return t.Scene.LoadPrimaryFile(ctx, r.File)
}); err != nil { }); err != nil {
logger.Error(err) logger.Error(err)
return
} }
if !required { if !required {

View file

@ -462,6 +462,7 @@ type ScrapedGroup struct {
Date *string `json:"date"` Date *string `json:"date"`
Rating *string `json:"rating"` Rating *string `json:"rating"`
Director *string `json:"director"` Director *string `json:"director"`
URL *string `json:"url"` // included for backward compatibility
URLs []string `json:"urls"` URLs []string `json:"urls"`
Synopsis *string `json:"synopsis"` Synopsis *string `json:"synopsis"`
Studio *ScrapedStudio `json:"studio"` Studio *ScrapedStudio `json:"studio"`

View file

@ -126,6 +126,7 @@ type mappedSceneScraperConfig struct {
Performers mappedPerformerScraperConfig `yaml:"Performers"` Performers mappedPerformerScraperConfig `yaml:"Performers"`
Studio mappedConfig `yaml:"Studio"` Studio mappedConfig `yaml:"Studio"`
Movies mappedConfig `yaml:"Movies"` Movies mappedConfig `yaml:"Movies"`
Groups mappedConfig `yaml:"Groups"`
} }
type _mappedSceneScraperConfig mappedSceneScraperConfig type _mappedSceneScraperConfig mappedSceneScraperConfig
@ -134,6 +135,7 @@ const (
mappedScraperConfigScenePerformers = "Performers" mappedScraperConfigScenePerformers = "Performers"
mappedScraperConfigSceneStudio = "Studio" mappedScraperConfigSceneStudio = "Studio"
mappedScraperConfigSceneMovies = "Movies" mappedScraperConfigSceneMovies = "Movies"
mappedScraperConfigSceneGroups = "Groups"
) )
func (s *mappedSceneScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { 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[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers]
thisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio] thisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio]
thisMap[mappedScraperConfigSceneMovies] = parentMap[mappedScraperConfigSceneMovies] thisMap[mappedScraperConfigSceneMovies] = parentMap[mappedScraperConfigSceneMovies]
thisMap[mappedScraperConfigSceneGroups] = parentMap[mappedScraperConfigSceneGroups]
delete(parentMap, mappedScraperConfigSceneTags) delete(parentMap, mappedScraperConfigSceneTags)
delete(parentMap, mappedScraperConfigScenePerformers) delete(parentMap, mappedScraperConfigScenePerformers)
delete(parentMap, mappedScraperConfigSceneStudio) delete(parentMap, mappedScraperConfigSceneStudio)
delete(parentMap, mappedScraperConfigSceneMovies) delete(parentMap, mappedScraperConfigSceneMovies)
delete(parentMap, mappedScraperConfigSceneGroups)
// re-unmarshal the sub-fields // re-unmarshal the sub-fields
yml, err := yaml.Marshal(thisMap) yml, err := yaml.Marshal(thisMap)
@ -873,9 +877,17 @@ func (r mappedResult) apply(dest interface{}) {
func mapFieldValue(destVal reflect.Value, key string, value interface{}) error { func mapFieldValue(destVal reflect.Value, key string, value interface{}) error {
field := destVal.FieldByName(key) 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() fieldType := field.Type()
if field.IsValid() && field.CanSet() {
switch v := value.(type) { switch v := value.(type) {
case string: 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 pointer to a string, then we need to convert the string to a pointer
@ -915,9 +927,6 @@ func mapFieldValue(destVal reflect.Value, key string, value interface{}) error {
return fmt.Errorf("cannot convert %T to %s", value, fieldType) return fmt.Errorf("cannot convert %T to %s", value, fieldType)
} }
} }
} else {
return fmt.Errorf("field does not exist or cannot be set")
}
return nil return nil
} }
@ -1008,6 +1017,7 @@ func (s mappedScraper) processSceneRelationships(ctx context.Context, q mappedQu
sceneTagsMap := sceneScraperConfig.Tags sceneTagsMap := sceneScraperConfig.Tags
sceneStudioMap := sceneScraperConfig.Studio sceneStudioMap := sceneScraperConfig.Studio
sceneMoviesMap := sceneScraperConfig.Movies sceneMoviesMap := sceneScraperConfig.Movies
sceneGroupsMap := sceneScraperConfig.Groups
ret.Performers = s.processPerformers(ctx, scenePerformersMap, q) 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) 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 { 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 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 // post-process - set the image if applicable
if err := setMovieFrontImage(ctx, c.client, &m, c.globalConfig); err != nil { 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) 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 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 // post-process - set the image if applicable
if err := setGroupFrontImage(ctx, c.client, &m, c.globalConfig); err != nil { 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) 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") f.addWhere("galleries_join.scene_id IS NULL")
case "studio": case "studio":
f.addWhere("scenes.studio_id IS NULL") f.addWhere("scenes.studio_id IS NULL")
case "movie": case "movie", "group":
sceneRepository.groups.join(f, "groups_join", "scenes.id") sceneRepository.groups.join(f, "groups_join", "scenes.id")
f.addWhere("groups_join.scene_id IS NULL") f.addWhere("groups_join.scene_id IS NULL")
case "performers": case "performers":

View file

@ -43,6 +43,7 @@ import cx from "classnames";
import { useRatingKeybinds } from "src/hooks/keybinds"; import { useRatingKeybinds } from "src/hooks/keybinds";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
import { TruncatedText } from "src/components/Shared/TruncatedText"; import { TruncatedText } from "src/components/Shared/TruncatedText";
import { goBackOrReplace } from "src/utils/history";
interface IProps { interface IProps {
gallery: GQL.GalleryDataFragment; gallery: GQL.GalleryDataFragment;
@ -167,7 +168,7 @@ export const GalleryPage: React.FC<IProps> = ({ gallery, add }) => {
function onDeleteDialogClosed(deleted: boolean) { function onDeleteDialogClosed(deleted: boolean) {
setIsDeleteAlertOpen(false); setIsDeleteAlertOpen(false);
if (deleted) { if (deleted) {
history.goBack(); goBackOrReplace(history, "/galleries");
} }
} }

View file

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

View file

@ -43,6 +43,7 @@ import { Button, Tab, Tabs } from "react-bootstrap";
import { GroupSubGroupsPanel } from "./GroupSubGroupsPanel"; import { GroupSubGroupsPanel } from "./GroupSubGroupsPanel";
import { GroupPerformersPanel } from "./GroupPerformersPanel"; import { GroupPerformersPanel } from "./GroupPerformersPanel";
import { Icon } from "src/components/Shared/Icon"; import { Icon } from "src/components/Shared/Icon";
import { goBackOrReplace } from "src/utils/history";
const validTabs = ["default", "scenes", "performers", "subgroups"] as const; const validTabs = ["default", "scenes", "performers", "subgroups"] as const;
type TabKey = (typeof validTabs)[number]; type TabKey = (typeof validTabs)[number];
@ -276,7 +277,7 @@ const GroupPage: React.FC<IProps> = ({ group, tabKey }) => {
return; return;
} }
history.goBack(); goBackOrReplace(history, "/groups");
} }
function toggleEditing(value?: boolean) { 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 * as GQL from "src/core/generated-graphql";
import { GroupList } from "../GroupList"; import { GroupList } from "../GroupList";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
@ -101,6 +101,18 @@ interface IGroupSubGroupsPanel {
group: GQL.GroupDataFragment; 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> = ({ export const GroupSubGroupsPanel: React.FC<IGroupSubGroupsPanel> = ({
active, active,
group, group,
@ -114,18 +126,6 @@ export const GroupSubGroupsPanel: React.FC<IGroupSubGroupsPanel> = ({
const filterHook = useContainingGroupFilterHook(group); 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( async function removeSubGroups(
result: GQL.FindGroupsQueryResult, result: GQL.FindGroupsQueryResult,
filter: ListFilterModel, filter: ListFilterModel,

View file

@ -34,6 +34,7 @@ import TextUtils from "src/utils/text";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import cx from "classnames"; import cx from "classnames";
import { TruncatedText } from "src/components/Shared/TruncatedText"; import { TruncatedText } from "src/components/Shared/TruncatedText";
import { goBackOrReplace } from "src/utils/history";
interface IProps { interface IProps {
image: GQL.ImageDataFragment; image: GQL.ImageDataFragment;
@ -156,7 +157,7 @@ const ImagePage: React.FC<IProps> = ({ image }) => {
function onDeleteDialogClosed(deleted: boolean) { function onDeleteDialogClosed(deleted: boolean) {
setIsDeleteAlertOpen(false); setIsDeleteAlertOpen(false);
if (deleted) { if (deleted) {
history.goBack(); goBackOrReplace(history, "/images");
} }
} }

View file

@ -50,6 +50,7 @@ interface ICriterionList {
optionSelected: (o?: CriterionOption) => void; optionSelected: (o?: CriterionOption) => void;
onRemoveCriterion: (c: string) => void; onRemoveCriterion: (c: string) => void;
onTogglePin: (c: CriterionOption) => void; onTogglePin: (c: CriterionOption) => void;
externallySelected?: boolean;
} }
const CriterionOptionList: React.FC<ICriterionList> = ({ const CriterionOptionList: React.FC<ICriterionList> = ({
@ -62,6 +63,7 @@ const CriterionOptionList: React.FC<ICriterionList> = ({
optionSelected, optionSelected,
onRemoveCriterion, onRemoveCriterion,
onTogglePin, onTogglePin,
externallySelected = false,
}) => { }) => {
const prevCriterion = usePrevious(currentCriterion); const prevCriterion = usePrevious(currentCriterion);
@ -101,14 +103,19 @@ const CriterionOptionList: React.FC<ICriterionList> = ({
// scrolling to the current criterion doesn't work well when the // scrolling to the current criterion doesn't work well when the
// dialog is already open, so limit to when we click on the // dialog is already open, so limit to when we click on the
// criterion from the external tags // criterion from the external tags
if (!scrolled.current && type && criteriaRefs[type]?.current) { if (
externallySelected &&
!scrolled.current &&
type &&
criteriaRefs[type]?.current
) {
criteriaRefs[type].current!.scrollIntoView({ criteriaRefs[type].current!.scrollIntoView({
behavior: "smooth", behavior: "smooth",
block: "start", block: "start",
}); });
scrolled.current = true; scrolled.current = true;
} }
}, [currentCriterion, criteriaRefs, type]); }, [externallySelected, currentCriterion, criteriaRefs, type]);
function getReleventCriterion(t: CriterionType) { function getReleventCriterion(t: CriterionType) {
if (currentCriterion?.criterionOption.type === t) { if (currentCriterion?.criterionOption.type === t) {
@ -549,6 +556,7 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
selected={criterion?.criterionOption} selected={criterion?.criterionOption}
onRemoveCriterion={(c) => removeCriterionString(c)} onRemoveCriterion={(c) => removeCriterionString(c)}
onTogglePin={(c) => onTogglePinFilter(c)} onTogglePin={(c) => onTogglePinFilter(c)}
externallySelected={!!editingCriterion}
/> />
{criteria.length > 0 && ( {criteria.length > 0 && (
<div> <div>

View file

@ -54,6 +54,7 @@ interface ISidebarFilter {
option: CriterionOption; option: CriterionOption;
filter: ListFilterModel; filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void; setFilter: (f: ListFilterModel) => void;
sectionID?: string;
} }
export const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({ export const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({
@ -61,6 +62,7 @@ export const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({
option, option,
filter, filter,
setFilter, setFilter,
sectionID,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
@ -127,6 +129,7 @@ export const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({
onUnselect={onUnselect} onUnselect={onUnselect}
selected={selected} selected={selected}
singleValue singleValue
sectionID={sectionID}
/> />
</> </>
); );

View file

@ -10,6 +10,8 @@ import ScreenUtils from "src/utils/screen";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import { Button } from "react-bootstrap"; import { Button } from "react-bootstrap";
const savedFiltersSectionID = "saved-filters";
export const FilteredSidebarHeader: React.FC<{ export const FilteredSidebarHeader: React.FC<{
sidebarOpen: boolean; sidebarOpen: boolean;
showEditFilter: () => void; showEditFilter: () => void;
@ -60,6 +62,7 @@ export const FilteredSidebarHeader: React.FC<{
<SidebarSection <SidebarSection
className="sidebar-saved-filters" className="sidebar-saved-filters"
text={<FormattedMessage id="search_filter.saved_filters" />} text={<FormattedMessage id="search_filter.saved_filters" />}
sectionID={savedFiltersSectionID}
> >
<SidebarSavedFilterList <SidebarSavedFilterList
filter={filter} filter={filter}

View file

@ -110,7 +110,8 @@ export const SidebarPerformersFilter: React.FC<{
filter: ListFilterModel; filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void; setFilter: (f: ListFilterModel) => void;
filterHook?: (f: ListFilterModel) => ListFilterModel; filterHook?: (f: ListFilterModel) => ListFilterModel;
}> = ({ title, option, filter, setFilter, filterHook }) => { sectionID?: string;
}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => {
const state = useLabeledIdFilterState({ const state = useLabeledIdFilterState({
filter, filter,
setFilter, setFilter,
@ -119,7 +120,7 @@ export const SidebarPerformersFilter: React.FC<{
useQuery: usePerformerQueryFilter, useQuery: usePerformerQueryFilter,
}); });
return <SidebarListFilter {...state} title={title} />; return <SidebarListFilter {...state} title={title} sectionID={sectionID} />;
}; };
export default PerformersFilter; export default PerformersFilter;

View file

@ -77,6 +77,7 @@ interface ISidebarFilter {
option: CriterionOption; option: CriterionOption;
filter: ListFilterModel; filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void; setFilter: (f: ListFilterModel) => void;
sectionID?: string;
} }
const any = "any"; const any = "any";
@ -87,6 +88,7 @@ export const SidebarRatingFilter: React.FC<ISidebarFilter> = ({
option, option,
filter, filter,
setFilter, setFilter,
sectionID,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
@ -199,6 +201,7 @@ export const SidebarRatingFilter: React.FC<ISidebarFilter> = ({
singleValue singleValue
preCandidates={ratingValue === null ? ratingStars : undefined} preCandidates={ratingValue === null ? ratingStars : undefined}
preSelected={ratingValue !== null ? ratingStars : undefined} preSelected={ratingValue !== null ? ratingStars : undefined}
sectionID={sectionID}
/> />
<div></div> <div></div>
</> </>

View file

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

View file

@ -98,7 +98,8 @@ export const SidebarStudiosFilter: React.FC<{
filter: ListFilterModel; filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void; setFilter: (f: ListFilterModel) => void;
filterHook?: (f: ListFilterModel) => ListFilterModel; filterHook?: (f: ListFilterModel) => ListFilterModel;
}> = ({ title, option, filter, setFilter, filterHook }) => { sectionID?: string;
}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => {
const state = useLabeledIdFilterState({ const state = useLabeledIdFilterState({
filter, filter,
setFilter, setFilter,
@ -110,7 +111,7 @@ export const SidebarStudiosFilter: React.FC<{
includeSubMessageID: "subsidiary_studios", includeSubMessageID: "subsidiary_studios",
}); });
return <SidebarListFilter {...state} title={title} />; return <SidebarListFilter {...state} title={title} sectionID={sectionID} />;
}; };
export default StudiosFilter; export default StudiosFilter;

View file

@ -103,7 +103,8 @@ export const SidebarTagsFilter: React.FC<{
filter: ListFilterModel; filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void; setFilter: (f: ListFilterModel) => void;
filterHook?: (f: ListFilterModel) => ListFilterModel; filterHook?: (f: ListFilterModel) => ListFilterModel;
}> = ({ title, option, filter, setFilter, filterHook }) => { sectionID?: string;
}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => {
const state = useLabeledIdFilterState({ const state = useLabeledIdFilterState({
filter, filter,
setFilter, setFilter,
@ -114,7 +115,7 @@ export const SidebarTagsFilter: React.FC<{
includeSubMessageID: "sub_tags", includeSubMessageID: "sub_tags",
}); });
return <SidebarListFilter {...state} title={title} />; return <SidebarListFilter {...state} title={title} sectionID={sectionID} />;
}; };
export default TagsFilter; export default TagsFilter;

View file

@ -16,22 +16,28 @@ import {
faTrash, faTrash,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import cx from "classnames"; import cx from "classnames";
import { createPortal } from "react-dom";
export const OperationDropdown: React.FC< export const OperationDropdown: React.FC<
PropsWithChildren<{ PropsWithChildren<{
className?: string; className?: string;
menuPortalTarget?: HTMLElement;
}> }>
> = ({ className, children }) => { > = ({ className, menuPortalTarget, children }) => {
if (!children) return null; if (!children) return null;
const menu = (
<Dropdown.Menu className="bg-secondary text-white">
{children}
</Dropdown.Menu>
);
return ( return (
<Dropdown className={className} as={ButtonGroup}> <Dropdown className={className} as={ButtonGroup}>
<Dropdown.Toggle variant="secondary" id="more-menu"> <Dropdown.Toggle variant="secondary" id="more-menu">
<Icon icon={faEllipsisH} /> <Icon icon={faEllipsisH} />
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu className="bg-secondary text-white"> {menuPortalTarget ? createPortal(menu, menuPortalTarget) : menu}
{children}
</Dropdown.Menu>
</Dropdown> </Dropdown>
); );
}; };

View file

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { ListFilterModel } from "src/models/list-filter/filter"; 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 { ButtonToolbar } from "react-bootstrap";
import { ListViewOptions } from "../List/ListViewOptions"; import { ListViewOptions } from "../List/ListViewOptions";
import { PageSizeSelector, SortBySelect } from "../List/ListFilter"; import { PageSizeSelector, SortBySelect } from "../List/ListFilter";
@ -23,15 +23,6 @@ export const ListResultsHeader: React.FC<{
}) => { }) => {
return ( return (
<ButtonToolbar className={cx(className, "list-results-header")}> <ButtonToolbar className={cx(className, "list-results-header")}>
<div>
<PaginationIndex
loading={loading}
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
metadataByline={metadataByline}
/>
</div>
<div> <div>
<SortBySelect <SortBySelect
options={filter.options.sortByOptions} options={filter.options.sortByOptions}
@ -61,6 +52,22 @@ export const ListResultsHeader: React.FC<{
onSetZoom={(zoom) => onChangeFilter(filter.setZoom(zoom))} onSetZoom={(zoom) => onChangeFilter(filter.setZoom(zoom))}
/> />
</div> </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> </ButtonToolbar>
); );
}; };

View file

@ -4,13 +4,15 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { faTimes } from "@fortawesome/free-solid-svg-icons"; import { faTimes } from "@fortawesome/free-solid-svg-icons";
import { FilterTags } from "../List/FilterTags"; import { FilterTags } from "../List/FilterTags";
import cx from "classnames"; import cx from "classnames";
import { Button, ButtonToolbar } from "react-bootstrap"; import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap";
import { FilterButton } from "../List/Filters/FilterButton"; import { FilterButton } from "../List/Filters/FilterButton";
import { Icon } from "../Shared/Icon"; import { Icon } from "../Shared/Icon";
import { SearchTermInput } from "../List/ListFilter"; import { SearchTermInput } from "../List/ListFilter";
import { Criterion } from "src/models/list-filter/criteria/criterion"; import { Criterion } from "src/models/list-filter/criteria/criterion";
import { SidebarToggleButton } from "../Shared/Sidebar"; import { SidebarToggleButton } from "../Shared/Sidebar";
import { PatchComponent } from "src/patch"; import { PatchComponent } from "src/patch";
import { SavedFilterDropdown } from "./SavedFilterList";
import { View } from "./views";
export const ToolbarFilterSection: React.FC<{ export const ToolbarFilterSection: React.FC<{
filter: ListFilterModel; filter: ListFilterModel;
@ -21,6 +23,7 @@ export const ToolbarFilterSection: React.FC<{
onRemoveAllCriterion: () => void; onRemoveAllCriterion: () => void;
onEditSearchTerm: () => void; onEditSearchTerm: () => void;
onRemoveSearchTerm: () => void; onRemoveSearchTerm: () => void;
view?: View;
}> = PatchComponent( }> = PatchComponent(
"ToolbarFilterSection", "ToolbarFilterSection",
({ ({
@ -32,6 +35,7 @@ export const ToolbarFilterSection: React.FC<{
onRemoveAllCriterion, onRemoveAllCriterion,
onEditSearchTerm, onEditSearchTerm,
onRemoveSearchTerm, onRemoveSearchTerm,
view,
}) => { }) => {
const { criteria, searchTerm } = filter; const { criteria, searchTerm } = filter;
@ -41,10 +45,19 @@ export const ToolbarFilterSection: React.FC<{
<SearchTermInput filter={filter} onFilterUpdate={onSetFilter} /> <SearchTermInput filter={filter} onFilterUpdate={onSetFilter} />
</div> </div>
<div className="filter-section"> <div className="filter-section">
<ButtonGroup>
<SidebarToggleButton onClick={onToggleSidebar} />
<SavedFilterDropdown
filter={filter}
onSetFilter={onSetFilter}
view={view}
menuPortalTarget={document.body}
/>
<FilterButton <FilterButton
onClick={() => onEditCriterion()} onClick={() => onEditCriterion()}
count={criteria.length} count={criteria.length}
/> />
</ButtonGroup>
<FilterTags <FilterTags
searchTerm={searchTerm} searchTerm={searchTerm}
criteria={criteria} criteria={criteria}
@ -55,7 +68,6 @@ export const ToolbarFilterSection: React.FC<{
onRemoveSearchTerm={onRemoveSearchTerm} onRemoveSearchTerm={onRemoveSearchTerm}
truncateOnOverflow truncateOnOverflow
/> />
<SidebarToggleButton onClick={onToggleSidebar} />
</div> </div>
</> </>
); );
@ -65,15 +77,18 @@ export const ToolbarFilterSection: React.FC<{
export const ToolbarSelectionSection: React.FC<{ export const ToolbarSelectionSection: React.FC<{
selected: number; selected: number;
onToggleSidebar: () => void; onToggleSidebar: () => void;
operations?: React.ReactNode;
onSelectAll: () => void; onSelectAll: () => void;
onSelectNone: () => void; onSelectNone: () => void;
}> = PatchComponent( }> = PatchComponent(
"ToolbarSelectionSection", "ToolbarSelectionSection",
({ selected, onToggleSidebar, onSelectAll, onSelectNone }) => { ({ selected, onToggleSidebar, operations, onSelectAll, onSelectNone }) => {
const intl = useIntl(); const intl = useIntl();
return ( return (
<div className="toolbar-selection-section">
<div className="selected-items-info"> <div className="selected-items-info">
<SidebarToggleButton onClick={onToggleSidebar} />
<Button <Button
variant="secondary" variant="secondary"
className="minimal" className="minimal"
@ -86,7 +101,9 @@ export const ToolbarSelectionSection: React.FC<{
<Button variant="link" onClick={() => onSelectAll()}> <Button variant="link" onClick={() => onSelectAll()}>
<FormattedMessage id="actions.select_all" /> <FormattedMessage id="actions.select_all" />
</Button> </Button>
<SidebarToggleButton onClick={onToggleSidebar} /> </div>
{operations}
<div className="empty-space" />
</div> </div>
); );
} }
@ -114,7 +131,11 @@ export const FilteredListToolbar2: React.FC<{
})} })}
> >
{!hasSelection ? filterSection : selectionSection} {!hasSelection ? filterSection : selectionSection}
<div className="filtered-list-toolbar-operations">{operationSection}</div> {!hasSelection ? (
<div className="filtered-list-toolbar-operations">
{operationSection}
</div>
) : null}
</ButtonToolbar> </ButtonToolbar>
); );
}; };

View file

@ -44,7 +44,7 @@ const PageCount: React.FC<{
useStopWheelScroll(pageInput); useStopWheelScroll(pageInput);
const pageOptions = useMemo(() => { const pageOptions = useMemo(() => {
const maxPagesToShow = 10; const maxPagesToShow = 1000;
const min = Math.max(1, currentPage - maxPagesToShow / 2); const min = Math.max(1, currentPage - maxPagesToShow / 2);
const max = Math.min(min + maxPagesToShow, totalPages); const max = Math.min(min + maxPagesToShow, totalPages);
const pages = []; const pages = [];

View file

@ -31,6 +31,7 @@ import { AlertModal } from "../Shared/Alert";
import cx from "classnames"; import cx from "classnames";
import { TruncatedInlineText } from "../Shared/TruncatedText"; import { TruncatedInlineText } from "../Shared/TruncatedText";
import { OperationButton } from "../Shared/OperationButton"; import { OperationButton } from "../Shared/OperationButton";
import { createPortal } from "react-dom";
const ExistingSavedFilterList: React.FC<{ const ExistingSavedFilterList: React.FC<{
name: string; name: string;
@ -243,6 +244,7 @@ interface ISavedFilterListProps {
filter: ListFilterModel; filter: ListFilterModel;
onSetFilter: (f: ListFilterModel) => void; onSetFilter: (f: ListFilterModel) => void;
view?: View; view?: View;
menuPortalTarget?: Element | DocumentFragment;
} }
export const SavedFilterList: React.FC<ISavedFilterListProps> = ({ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
@ -841,8 +843,15 @@ export const SavedFilterDropdown: React.FC<ISavedFilterListProps> = (props) => {
)); ));
SavedFilterDropdownRef.displayName = "SavedFilterDropdown"; SavedFilterDropdownRef.displayName = "SavedFilterDropdown";
const menu = (
<Dropdown.Menu
as={SavedFilterDropdownRef}
className="saved-filter-list-menu"
/>
);
return ( return (
<Dropdown as={ButtonGroup}> <Dropdown as={ButtonGroup} className="saved-filter-dropdown">
<OverlayTrigger <OverlayTrigger
placement="top" placement="top"
overlay={ overlay={
@ -855,10 +864,9 @@ export const SavedFilterDropdown: React.FC<ISavedFilterListProps> = (props) => {
<Icon icon={faBookmark} /> <Icon icon={faBookmark} />
</Dropdown.Toggle> </Dropdown.Toggle>
</OverlayTrigger> </OverlayTrigger>
<Dropdown.Menu {props.menuPortalTarget
as={SavedFilterDropdownRef} ? createPortal(menu, props.menuPortalTarget)
className="saved-filter-list-menu" : menu}
/>
</Dropdown> </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 { .display-mode-popover {
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;
@ -1048,7 +1055,7 @@ input[type="range"].zoom-slider {
} }
// hide sidebar Edit Filter button on larger screens // hide sidebar Edit Filter button on larger screens
@include media-breakpoint-up(lg) { @include media-breakpoint-up(md) {
.sidebar .edit-filter-button { .sidebar .edit-filter-button {
display: none; display: none;
} }
@ -1064,6 +1071,7 @@ input[type="range"].zoom-slider {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
margin-bottom: 0;
row-gap: 1rem; row-gap: 1rem;
> div { > div {
@ -1094,10 +1102,6 @@ input[type="range"].zoom-slider {
top: 0; top: 0;
} }
.selected-items-info .btn {
margin-right: 0.5rem;
}
// hide drop down menu items for play and create new // hide drop down menu items for play and create new
// when the buttons are visible // when the buttons are visible
@include media-breakpoint-up(sm) { @include media-breakpoint-up(sm) {
@ -1118,7 +1122,7 @@ input[type="range"].zoom-slider {
} }
} }
.selected-items-info, .toolbar-selection-section,
div.filter-section { div.filter-section {
border: 1px solid $secondary; border: 1px solid $secondary;
border-radius: 0.25rem; border-radius: 0.25rem;
@ -1126,13 +1130,69 @@ input[type="range"].zoom-slider {
overflow-x: hidden; overflow-x: hidden;
} }
div.toolbar-selection-section {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
.sidebar-toggle-button { .sidebar-toggle-button {
margin-left: auto; 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 { .search-container {
border-right: 1px solid $secondary; border-right: 1px solid $secondary;
display: block; display: flex;
margin-right: -0.5rem; margin-right: -0.5rem;
min-width: calc($sidebar-width - 15px); min-width: calc($sidebar-width - 15px);
padding-right: 10px; padding-right: 10px;
@ -1168,22 +1228,28 @@ 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 { .sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar .search-container {
display: none; display: none;
} }
} }
// hide the search box when sidebar is hidden on smaller screens
@include media-breakpoint-down(md) { @include media-breakpoint-down(md) {
.sidebar-pane.hide-sidebar .filtered-list-toolbar .search-container { .sidebar-pane.hide-sidebar .filtered-list-toolbar .search-container {
display: none; display: none;
} }
} }
// hide the filter icon button when sidebar is shown on smaller screens // hide the filter and saved filters icon buttons when sidebar is shown on smaller screens
@include media-breakpoint-down(md) { @include media-breakpoint-down(sm) {
.sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar .filter-button { .sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar {
.filter-button,
.saved-filter-dropdown {
display: none; display: none;
} }
}
// adjust the width of the filter-tags as well // adjust the width of the filter-tags as well
.sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar .filter-tags { .sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar .filter-tags {
@ -1191,8 +1257,8 @@ input[type="range"].zoom-slider {
} }
} }
// move the sidebar toggle to the left on xl viewports // move the sidebar toggle to the left on larger viewports
@include media-breakpoint-up(xl) { @include media-breakpoint-up(md) {
.filtered-list-toolbar .filter-section { .filtered-list-toolbar .filter-section {
.sidebar-toggle-button { .sidebar-toggle-button {
margin-left: 0; margin-left: 0;
@ -1239,17 +1305,21 @@ input[type="range"].zoom-slider {
} }
.list-results-header { .list-results-header {
align-items: center; align-items: flex-start;
background-color: $body-bg; background-color: $body-bg;
display: flex; display: flex;
justify-content: space-between;
> div { > div {
align-items: center; align-items: center;
display: flex; display: flex;
flex: 1;
gap: 0.5rem; gap: 0.5rem;
justify-content: flex-start; justify-content: flex-start;
&.pagination-index-container {
justify-content: center;
}
&:last-child { &:last-child {
flex-shrink: 0; flex-shrink: 0;
justify-content: flex-end; justify-content: flex-end;
@ -1257,19 +1327,72 @@ input[type="range"].zoom-slider {
} }
} }
.list-results-header { .list-results-header .pagination-index-container {
flex-wrap: wrap-reverse; display: flex;
flex-direction: column;
gap: 0.5rem; 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; margin-bottom: 0.5rem;
.paginationIndex { .paginationIndex {
margin: 0; 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 // center the header on smaller screens
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
& > div, & > 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%; flex-basis: 100%;
justify-content: center; justify-content: center;
margin-left: auto; 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 { DisplayMode } from "src/models/list-filter/types";
import { Criterion } from "src/models/list-filter/criteria/criterion"; 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( export function useFilterURL(
filter: ListFilterModel, filter: ListFilterModel,
setFilter: React.Dispatch<React.SetStateAction<ListFilterModel>>, setFilter: React.Dispatch<React.SetStateAction<ListFilterModel>>,
@ -49,7 +56,7 @@ export function useFilterURL(
useEffect(() => { useEffect(() => {
// don't apply if active is false // don't apply if active is false
// also don't apply if location is unchanged // 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 // re-init to load default filter on empty new query params
if (!location.search) { 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 { LightboxLink } from "src/hooks/Lightbox/LightboxLink";
import { PatchComponent } from "src/patch"; import { PatchComponent } from "src/patch";
import { ILightboxImage } from "src/hooks/Lightbox/types"; import { ILightboxImage } from "src/hooks/Lightbox/types";
import { goBackOrReplace } from "src/utils/history";
interface IProps { interface IProps {
performer: GQL.PerformerDataFragment; performer: GQL.PerformerDataFragment;
@ -330,7 +331,7 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
return; return;
} }
history.goBack(); goBackOrReplace(history, "/performers");
} }
function toggleEditing(value?: boolean) { function toggleEditing(value?: boolean) {

View file

@ -51,6 +51,7 @@ import { lazyComponent } from "src/utils/lazyComponent";
import cx from "classnames"; import cx from "classnames";
import { TruncatedText } from "src/components/Shared/TruncatedText"; import { TruncatedText } from "src/components/Shared/TruncatedText";
import { PatchComponent, PatchContainerComponent } from "src/patch"; import { PatchComponent, PatchContainerComponent } from "src/patch";
import { goBackOrReplace } from "src/utils/history";
const SubmitStashBoxDraft = lazyComponent( const SubmitStashBoxDraft = lazyComponent(
() => import("src/components/Dialogs/SubmitDraft") () => import("src/components/Dialogs/SubmitDraft")
@ -909,7 +910,7 @@ const SceneLoader: React.FC<RouteComponentProps<ISceneParams>> = ({
) { ) {
loadScene(queueScenes[currentQueueIndex + 1].id); loadScene(queueScenes[currentQueueIndex + 1].id);
} else { } else {
history.goBack(); goBackOrReplace(history, "/scenes");
} }
} }

View file

@ -37,7 +37,13 @@ import {
OperationDropdownItem, OperationDropdownItem,
} from "../List/ListOperationButtons"; } from "../List/ListOperationButtons";
import { useFilteredItemList } from "../List/ItemList"; 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 { SidebarPerformersFilter } from "../List/Filters/PerformersFilter";
import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter"; import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter";
import { PerformersCriterionOption } from "src/models/list-filter/criteria/performers"; import { PerformersCriterionOption } from "src/models/list-filter/criteria/performers";
@ -285,6 +291,7 @@ const SidebarContent: React.FC<{
filter={filter} filter={filter}
setFilter={setFilter} setFilter={setFilter}
filterHook={filterHook} filterHook={filterHook}
sectionID="studios"
/> />
)} )}
<SidebarPerformersFilter <SidebarPerformersFilter
@ -294,6 +301,7 @@ const SidebarContent: React.FC<{
filter={filter} filter={filter}
setFilter={setFilter} setFilter={setFilter}
filterHook={filterHook} filterHook={filterHook}
sectionID="performers"
/> />
<SidebarTagsFilter <SidebarTagsFilter
title={<FormattedMessage id="tags" />} title={<FormattedMessage id="tags" />}
@ -302,6 +310,7 @@ const SidebarContent: React.FC<{
filter={filter} filter={filter}
setFilter={setFilter} setFilter={setFilter}
filterHook={filterHook} filterHook={filterHook}
sectionID="tags"
/> />
<SidebarRatingFilter <SidebarRatingFilter
title={<FormattedMessage id="rating" />} title={<FormattedMessage id="rating" />}
@ -309,6 +318,7 @@ const SidebarContent: React.FC<{
option={RatingCriterionOption} option={RatingCriterionOption}
filter={filter} filter={filter}
setFilter={setFilter} setFilter={setFilter}
sectionID="rating"
/> />
<SidebarBooleanFilter <SidebarBooleanFilter
title={<FormattedMessage id="organized" />} title={<FormattedMessage id="organized" />}
@ -316,6 +326,7 @@ const SidebarContent: React.FC<{
option={OrganizedCriterionOption} option={OrganizedCriterionOption}
filter={filter} filter={filter}
setFilter={setFilter} setFilter={setFilter}
sectionID="organized"
/> />
</ScenesFilterSidebarSections> </ScenesFilterSidebarSections>
@ -355,7 +366,7 @@ const SceneListOperations: React.FC<{
const intl = useIntl(); const intl = useIntl();
return ( return (
<div> <div className="scene-list-operations">
<ButtonGroup> <ButtonGroup>
{!!items && ( {!!items && (
<Button <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) => { {operations.map((o) => {
if (o.isDisplayed && !o.isDisplayed()) { if (o.isDisplayed && !o.isDisplayed()) {
return null; return null;
@ -439,6 +453,8 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
showSidebar, showSidebar,
setShowSidebar, setShowSidebar,
loading: sidebarStateLoading, loading: sidebarStateLoading,
sectionOpen,
setSectionOpen,
} = useSidebarState(view); } = useSidebarState(view);
const { filterState, queryResult, modalState, listSelect, showEditFilter } = const { filterState, queryResult, modalState, listSelect, showEditFilter } =
@ -518,7 +534,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]); const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]);
const playRandom = usePlayRandom(filter, totalCount); const playRandom = usePlayRandom(effectiveFilter, totalCount);
const playSelected = usePlaySelected(selectedIds); const playSelected = usePlaySelected(selectedIds);
const playFirst = usePlayFirst(); const playFirst = usePlayFirst();
@ -666,6 +682,18 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
// render // render
if (filterLoading || sidebarStateLoading) return null; if (filterLoading || sidebarStateLoading) return null;
const operations = (
<SceneListOperations
items={items.length}
hasSelection={hasSelection}
operations={otherOperations}
onEdit={onEdit}
onDelete={onDelete}
onPlay={onPlay}
onCreateNew={onCreateNew}
/>
);
return ( return (
<TaggerContext> <TaggerContext>
<div <div
@ -675,6 +703,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
> >
{modal} {modal}
<SidebarStateContext.Provider value={{ sectionOpen, setSectionOpen }}>
<SidebarPane hideSidebar={!showSidebar}> <SidebarPane hideSidebar={!showSidebar}>
<Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}> <Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>
<SidebarContent <SidebarContent
@ -689,7 +718,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
focus={searchFocus} focus={searchFocus}
/> />
</Sidebar> </Sidebar>
<div> <SidebarPaneContent>
<FilteredListToolbar2 <FilteredListToolbar2
className="scene-list-toolbar" className="scene-list-toolbar"
hasSelection={hasSelection} hasSelection={hasSelection}
@ -707,7 +736,10 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
setShowSidebar(true); setShowSidebar(true);
setSearchFocus(true); setSearchFocus(true);
}} }}
onRemoveSearchTerm={() => setFilter(filter.clearSearchTerm())} onRemoveSearchTerm={() =>
setFilter(filter.clearSearchTerm())
}
view={view}
/> />
} }
selectionSection={ selectionSection={
@ -716,19 +748,10 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
onToggleSidebar={() => setShowSidebar(!showSidebar)} onToggleSidebar={() => setShowSidebar(!showSidebar)}
onSelectAll={() => onSelectAll()} onSelectAll={() => onSelectAll()}
onSelectNone={() => onSelectNone()} onSelectNone={() => onSelectNone()}
operations={operations}
/> />
} }
operationSection={ operationSection={operations}
<SceneListOperations
items={items.length}
hasSelection={hasSelection}
operations={otherOperations}
onEdit={onEdit}
onDelete={onDelete}
onPlay={onPlay}
onCreateNew={onCreateNew}
/>
}
/> />
<ListResultsHeader <ListResultsHeader
@ -761,8 +784,9 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
/> />
</div> </div>
)} )}
</div> </SidebarPaneContent>
</SidebarPane> </SidebarPane>
</SidebarStateContext.Provider>
</div> </div>
</TaggerContext> </TaggerContext>
); );

View file

@ -3,7 +3,7 @@ import {
faChevronRight, faChevronRight,
faChevronUp, faChevronUp,
} from "@fortawesome/free-solid-svg-icons"; } 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 { Button, Collapse, CollapseProps } from "react-bootstrap";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
@ -12,21 +12,26 @@ interface IProps {
text: React.ReactNode; text: React.ReactNode;
collapseProps?: Partial<CollapseProps>; collapseProps?: Partial<CollapseProps>;
outsideCollapse?: React.ReactNode; outsideCollapse?: React.ReactNode;
onOpen?: () => void; onOpenChanged?: (o: boolean) => void;
open?: boolean;
} }
export const CollapseButton: React.FC<React.PropsWithChildren<IProps>> = ( export const CollapseButton: React.FC<React.PropsWithChildren<IProps>> = (
props: React.PropsWithChildren<IProps> props: React.PropsWithChildren<IProps>
) => { ) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(props.open ?? false);
function toggleOpen() { function toggleOpen() {
const nv = !open; const nv = !open;
setOpen(nv); setOpen(nv);
if (props.onOpen && nv) { props.onOpenChanged?.(nv);
props.onOpen();
} }
useEffect(() => {
if (props.open !== undefined) {
setOpen(props.open);
} }
}, [props.open]);
return ( return (
<div className={props.className}> <div className={props.className}>

View file

@ -15,8 +15,12 @@ import { Button, CollapseProps } from "react-bootstrap";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { faSliders } from "@fortawesome/free-solid-svg-icons"; 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< export const Sidebar: React.FC<
PropsWithChildren<{ 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< export const SidebarSection: React.FC<
PropsWithChildren<{ PropsWithChildren<{
text: React.ReactNode; text: React.ReactNode;
className?: string; className?: string;
outsideCollapse?: React.ReactNode; outsideCollapse?: React.ReactNode;
onOpen?: () => void; 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> = { const collapseProps: Partial<CollapseProps> = {
mountOnEnter: true, mountOnEnter: true,
unmountOnExit: true, unmountOnExit: true,
@ -74,7 +117,8 @@ export const SidebarSection: React.FC<
collapseProps={collapseProps} collapseProps={collapseProps}
text={text} text={text}
outsideCollapse={outsideCollapse} outsideCollapse={outsideCollapse}
onOpen={onOpen} onOpenChanged={onOpenInternal}
open={openState}
> >
{children} {children}
</CollapseButton> </CollapseButton>
@ -87,7 +131,7 @@ export const SidebarToggleButton: React.FC<{
const intl = useIntl(); const intl = useIntl();
return ( return (
<Button <Button
className="minimal sidebar-toggle-button ignore-sidebar-outside-click" className="sidebar-toggle-button ignore-sidebar-outside-click"
variant="secondary" variant="secondary"
onClick={onClick} onClick={onClick}
title={intl.formatMessage({ id: "actions.sidebar.toggle" })} title={intl.formatMessage({ id: "actions.sidebar.toggle" })}
@ -105,6 +149,7 @@ export function defaultShowSidebar() {
export function useSidebarState(view?: View) { export function useSidebarState(view?: View) {
const [interfaceLocalForage, setInterfaceLocalForage] = const [interfaceLocalForage, setInterfaceLocalForage] =
useInterfaceLocalForage(); useInterfaceLocalForage();
const history = useHistory();
const { data: interfaceLocalForageData, loading } = interfaceLocalForage; const { data: interfaceLocalForageData, loading } = interfaceLocalForage;
@ -113,6 +158,7 @@ export function useSidebarState(view?: View) {
}, [view, interfaceLocalForageData]); }, [view, interfaceLocalForageData]);
const [showSidebar, setShowSidebar] = useState<boolean>(); const [showSidebar, setShowSidebar] = useState<boolean>();
const [sectionOpen, setSectionOpen] = useState<SidebarSectionStates>();
// set initial state once loading is done // set initial state once loading is done
useEffect(() => { useEffect(() => {
@ -127,7 +173,17 @@ export function useSidebarState(view?: View) {
// only show sidebar by default on large screens // only show sidebar by default on large screens
setShowSidebar(!!viewConfig.showSidebar && defaultShowSidebar()); 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( const onSetShowSidebar = useCallback(
(show: boolean | ((prevState: boolean | undefined) => boolean)) => { (show: boolean | ((prevState: boolean | undefined) => boolean)) => {
@ -149,9 +205,28 @@ export function useSidebarState(view?: View) {
[showSidebar, setInterfaceLocalForage, view, viewConfig] [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 { return {
showSidebar: showSidebar ?? defaultShowSidebar(), showSidebar: showSidebar ?? defaultShowSidebar(),
sectionOpen: sectionOpen || {},
setShowSidebar: onSetShowSidebar, setShowSidebar: onSetShowSidebar,
setSectionOpen: onSetSectionOpen,
loading: showSidebar === undefined, 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; transition: margin-left 0.1s;
&:not(.hide-sidebar) { &:not(.hide-sidebar) {
@ -910,12 +910,12 @@ $sticky-header-height: calc(50px + 3.3rem);
} }
// on smaller viewports we want the sidebar to overlap content // 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 { .sidebar-pane:not(.hide-sidebar) .sidebar {
margin-right: -$sidebar-width; margin-right: -$sidebar-width;
} }
.sidebar-pane > :nth-child(2) { .sidebar-pane > .sidebar-pane-content {
transition: none; transition: none;
} }
} }
@ -935,7 +935,7 @@ $sticky-header-height: calc(50px + 3.3rem);
display: none; display: none;
} }
} }
@include media-breakpoint-up(xl) { @include media-breakpoint-up(md) {
.sidebar-pane:not(.hide-sidebar) { .sidebar-pane:not(.hide-sidebar) {
> :nth-child(2) { > :nth-child(2) {
margin-left: 0; margin-left: 0;

View file

@ -47,6 +47,7 @@ import { FavoriteIcon } from "src/components/Shared/FavoriteIcon";
import { ExternalLinkButtons } from "src/components/Shared/ExternalLinksButton"; import { ExternalLinkButtons } from "src/components/Shared/ExternalLinksButton";
import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; import { AliasList } from "src/components/Shared/DetailsPage/AliasList";
import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage";
import { goBackOrReplace } from "src/utils/history";
interface IProps { interface IProps {
studio: GQL.StudioDataFragment; studio: GQL.StudioDataFragment;
@ -378,7 +379,7 @@ const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
return; return;
} }
history.goBack(); goBackOrReplace(history, "/studios");
} }
function renderDeleteAlert() { function renderDeleteAlert() {

View file

@ -49,6 +49,7 @@ import { ExpandCollapseButton } from "src/components/Shared/CollapseButton";
import { FavoriteIcon } from "src/components/Shared/FavoriteIcon"; import { FavoriteIcon } from "src/components/Shared/FavoriteIcon";
import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; import { AliasList } from "src/components/Shared/DetailsPage/AliasList";
import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage";
import { goBackOrReplace } from "src/utils/history";
interface IProps { interface IProps {
tag: GQL.TagDataFragment; tag: GQL.TagDataFragment;
@ -420,7 +421,7 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
return; return;
} }
history.goBack(); goBackOrReplace(history, "/tags");
} }
function renderDeleteAlert() { function renderDeleteAlert() {

View file

@ -5,6 +5,11 @@
### 🎨 Improvements ### 🎨 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)) * 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 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)) * 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)) * Include IP address in login errors in log. ([#5760](https://github.com/stashapp/stash/pull/5760))
### 🐛 Bug fixes ### 🐛 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 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 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)) * **[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 ## 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! > **⚠️ Note:** Don't forget to click `Save` after updating these directories!

View file

@ -314,6 +314,86 @@
"blobs_path": { "blobs_path": {
"description": "Къде във файловата система да се пазят бинарни данни. Позва се само ако се ползва Файлова система за блоб пазене. ВНИМАНИЕ: промяната ще изисква ръчно местене на съществуващи данни." "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_results": "Resultaat tonen",
"show_count_results": "{count} resultaten tonen", "show_count_results": "{count} resultaten tonen",
"play": "Afspelen" "play": "Afspelen",
"load_filter": "Laad filter"
}, },
"actions_name": "Acties", "actions_name": "Acties",
"age": "Leeftijd", "age": "Leeftijd",
@ -498,7 +499,8 @@
"source": "Bron", "source": "Bron",
"source_options": "{source} Opties", "source_options": "{source} Opties",
"sources": "Bronnen", "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.", "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.", "incremental_import": "Incrementele import uit een meegeleverde export zip-bestand.",
@ -528,7 +530,13 @@
"anonymising_database": "Anonimiseer database", "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_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.", "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": { "tools": {
"scene_duplicate_checker": "Scène Duplicator Checker", "scene_duplicate_checker": "Scène Duplicator Checker",
@ -547,7 +555,9 @@
"whitespace_chars": "WhiteSpace-tekens", "whitespace_chars": "WhiteSpace-tekens",
"whitespace_chars_desc": "Deze tekens worden vervangen door witruimte in de titel" "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": { "ui": {
"basic_settings": "Basis instellingen", "basic_settings": "Basis instellingen",
@ -576,7 +586,15 @@
"description": "Verwijder de mogelijkheid om nieuwe objecten te maken uit de dropdown menu", "description": "Verwijder de mogelijkheid om nieuwe objecten te maken uit de dropdown menu",
"heading": "Schakel het maken van dropdowns uit" "heading": "Schakel het maken van dropdowns uit"
}, },
"heading": "Aanpassen" "heading": "Aanpassen",
"rating_system": {
"type": {
"options": {
"decimal": "Decimaal",
"stars": "Sterren"
}
}
}
}, },
"funscript_offset": { "funscript_offset": {
"description": "Time Offset in milliseconden voor het afspelen van interactieve scripts.", "description": "Time Offset in milliseconden voor het afspelen van interactieve scripts.",
@ -654,7 +672,9 @@
"continue_playlist_default": { "continue_playlist_default": {
"description": "Speel de volgende scène in de wachtrij wanneer video is voltooid", "description": "Speel de volgende scène in de wachtrij wanneer video is voltooid",
"heading": "Ga Standaard door met de afspeellijst" "heading": "Ga Standaard door met de afspeellijst"
} },
"always_start_from_beginning": "Start video altijd vanaf het begin",
"enable_chromecast": "Chromecast inschakelen"
} }
}, },
"scene_wall": { "scene_wall": {
@ -672,7 +692,28 @@
"description": "Diavoorstelling is beschikbaar in galerijen in de muurweergavemodus", "description": "Diavoorstelling is beschikbaar in galerijen in de muurweergavemodus",
"heading": "Diavoorstellingsvertraging (in seconden)" "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" "advanced_mode": "Geavanceerde modus"
}, },

View file

@ -149,7 +149,8 @@
"play": "Oynat", "play": "Oynat",
"show_results": "Sonuçları göster", "show_results": "Sonuçları göster",
"show_count_results": "{count} sonucu göster", "show_count_results": "{count} sonucu göster",
"load": "Yükle" "load": "Yükle",
"load_filter": "Filtre yükle"
}, },
"actions_name": "Eylemler", "actions_name": "Eylemler",
"age": "Yaş", "age": "Yaş",
@ -1021,7 +1022,7 @@
"include_sub_tags": "Alt etiketleri dahil et", "include_sub_tags": "Alt etiketleri dahil et",
"instagram": "Instagram", "instagram": "Instagram",
"interactive": "Etkileşimli", "interactive": "Etkileşimli",
"interactive_speed": "Etkileşim hızı", "interactive_speed": "Etkileşimli Hız",
"isMissing": "Eksik", "isMissing": "Eksik",
"library": "Kütüphane", "library": "Kütüphane",
"loading": { "loading": {
@ -1036,7 +1037,7 @@
"checksum": "Sağlama Toplamı (checksum)", "checksum": "Sağlama Toplamı (checksum)",
"downloaded_from": "İndirildiği Yer", "downloaded_from": "İndirildiği Yer",
"hash": "Dosya İmzası (Hash)", "hash": "Dosya İmzası (Hash)",
"interactive_speed": "Etkileşim hızı", "interactive_speed": "Etkileşimli Hız",
"performer_card": { "performer_card": {
"age": "{age} {years_old}", "age": "{age} {years_old}",
"age_context": "Bu sahnede {age} {years_old}" "age_context": "Bu sahnede {age} {years_old}"
@ -1090,7 +1091,8 @@
"name": "Filtre", "name": "Filtre",
"saved_filters": "Kaydedilmiş filtreler", "saved_filters": "Kaydedilmiş filtreler",
"update_filter": "Filtreyi Güncelle", "update_filter": "Filtreyi Güncelle",
"edit_filter": "Filtreyi Düzenle" "edit_filter": "Filtreyi Düzenle",
"search_term": "Arama terimi"
}, },
"seconds": "Saniye", "seconds": "Saniye",
"settings": "Ayarlar", "settings": "Ayarlar",
@ -1135,7 +1137,7 @@
}, },
"paths": { "paths": {
"database_filename_empty_for_default": "veritabanı adı (varsayılan için boş bırakın)", "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)", "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", "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?", "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", "date",
"galleries", "galleries",
"studio", "studio",
"movie", "group",
"performers", "performers",
"tags", "tags",
"stash_id", "stash_id",

View file

@ -183,7 +183,7 @@ export class ListFilterModel {
ret.disp = Number.parseInt(params.disp, 10); ret.disp = Number.parseInt(params.disp, 10);
} }
if (params.q) { if (params.q) {
ret.q = params.q.trim(); ret.q = params.q;
} }
if (params.p) { if (params.p) {
ret.p = Number.parseInt(params.p, 10); 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);
}
}