diff --git a/internal/api/resolver_query_scraper.go b/internal/api/resolver_query_scraper.go index f0e89cd34..5875cd11e 100644 --- a/internal/api/resolver_query_scraper.go +++ b/internal/api/resolver_query_scraper.go @@ -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 } } diff --git a/internal/manager/task_generate_screenshot.go b/internal/manager/task_generate_screenshot.go index 77ad2be34..2f4031586 100644 --- a/internal/manager/task_generate_screenshot.go +++ b/internal/manager/task_generate_screenshot.go @@ -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 { diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 008a05c3d..f7a9d6255 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -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"` diff --git a/pkg/scraper/mapped.go b/pkg/scraper/mapped.go index 4b2559334..f89499176 100644 --- a/pkg/scraper/mapped.go +++ b/pkg/scraper/mapped.go @@ -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 { diff --git a/pkg/scraper/postprocessing.go b/pkg/scraper/postprocessing.go index e12c1664f..62aa53c72 100644 --- a/pkg/scraper/postprocessing.go +++ b/pkg/scraper/postprocessing.go @@ -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) diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index 2e63dad97..86432a4af 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -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": diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index 20023904b..5d7cdeb51 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -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 = ({ gallery, add }) => { function onDeleteDialogClosed(deleted: boolean) { setIsDeleteAlertOpen(false); if (deleted) { - history.goBack(); + goBackOrReplace(history, "/galleries"); } } diff --git a/ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx b/ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx index b89356810..79c6075c0 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx @@ -114,6 +114,7 @@ export const AddSubGroupsDialog: React.FC = ( onUpdate={(input) => setEntries(input)} excludeIDs={excludeIDs} filterHook={filterHook} + menuPortalTarget={document.body} /> diff --git a/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx index bd58a6682..b48f3b98c 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx @@ -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 = ({ group, tabKey }) => { return; } - history.goBack(); + goBackOrReplace(history, "/groups"); } function toggleEditing(value?: boolean) { diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx index a2bb26e95..a02cb6108 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx @@ -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 = ({ active, group, @@ -114,18 +126,6 @@ export const GroupSubGroupsPanel: React.FC = ({ 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, diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index 4ab6641d7..b19366032 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -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 = ({ image }) => { function onDeleteDialogClosed(deleted: boolean) { setIsDeleteAlertOpen(false); if (deleted) { - history.goBack(); + goBackOrReplace(history, "/images"); } } diff --git a/ui/v2.5/src/components/List/EditFilterDialog.tsx b/ui/v2.5/src/components/List/EditFilterDialog.tsx index e914b194e..5f6d43004 100644 --- a/ui/v2.5/src/components/List/EditFilterDialog.tsx +++ b/ui/v2.5/src/components/List/EditFilterDialog.tsx @@ -50,6 +50,7 @@ interface ICriterionList { optionSelected: (o?: CriterionOption) => void; onRemoveCriterion: (c: string) => void; onTogglePin: (c: CriterionOption) => void; + externallySelected?: boolean; } const CriterionOptionList: React.FC = ({ @@ -62,6 +63,7 @@ const CriterionOptionList: React.FC = ({ optionSelected, onRemoveCriterion, onTogglePin, + externallySelected = false, }) => { const prevCriterion = usePrevious(currentCriterion); @@ -101,14 +103,19 @@ const CriterionOptionList: React.FC = ({ // 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 = ({ selected={criterion?.criterionOption} onRemoveCriterion={(c) => removeCriterionString(c)} onTogglePin={(c) => onTogglePinFilter(c)} + externallySelected={!!editingCriterion} /> {criteria.length > 0 && (
diff --git a/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx b/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx index 18df1b9f1..657e9ddbd 100644 --- a/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx @@ -54,6 +54,7 @@ interface ISidebarFilter { option: CriterionOption; filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; + sectionID?: string; } export const SidebarBooleanFilter: React.FC = ({ @@ -61,6 +62,7 @@ export const SidebarBooleanFilter: React.FC = ({ option, filter, setFilter, + sectionID, }) => { const intl = useIntl(); @@ -127,6 +129,7 @@ export const SidebarBooleanFilter: React.FC = ({ onUnselect={onUnselect} selected={selected} singleValue + sectionID={sectionID} /> ); diff --git a/ui/v2.5/src/components/List/Filters/FilterSidebar.tsx b/ui/v2.5/src/components/List/Filters/FilterSidebar.tsx index 1623c83d7..6b6503993 100644 --- a/ui/v2.5/src/components/List/Filters/FilterSidebar.tsx +++ b/ui/v2.5/src/components/List/Filters/FilterSidebar.tsx @@ -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<{ } + sectionID={savedFiltersSectionID} > 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 ; + return ; }; export default PerformersFilter; diff --git a/ui/v2.5/src/components/List/Filters/RatingFilter.tsx b/ui/v2.5/src/components/List/Filters/RatingFilter.tsx index 86d6a905b..1a0f14452 100644 --- a/ui/v2.5/src/components/List/Filters/RatingFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/RatingFilter.tsx @@ -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 = ({ option, filter, setFilter, + sectionID, }) => { const intl = useIntl(); @@ -199,6 +201,7 @@ export const SidebarRatingFilter: React.FC = ({ singleValue preCandidates={ratingValue === null ? ratingStars : undefined} preSelected={ratingValue !== null ? ratingStars : undefined} + sectionID={sectionID} />
diff --git a/ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx b/ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx index 71a56f23d..fe9b7987c 100644 --- a/ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx @@ -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<{ {preSelected ?
{preSelected}
: null} diff --git a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx index 9065f2452..e922e688a 100644 --- a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx @@ -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 ; + return ; }; export default StudiosFilter; diff --git a/ui/v2.5/src/components/List/Filters/TagsFilter.tsx b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx index e8e25db56..f4c618ffa 100644 --- a/ui/v2.5/src/components/List/Filters/TagsFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx @@ -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 ; + return ; }; export default TagsFilter; diff --git a/ui/v2.5/src/components/List/ListOperationButtons.tsx b/ui/v2.5/src/components/List/ListOperationButtons.tsx index 6bb31339a..bdb87fa3f 100644 --- a/ui/v2.5/src/components/List/ListOperationButtons.tsx +++ b/ui/v2.5/src/components/List/ListOperationButtons.tsx @@ -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 = ( + + {children} + + ); + return ( - - {children} - + {menuPortalTarget ? createPortal(menu, menuPortalTarget) : menu} ); }; diff --git a/ui/v2.5/src/components/List/ListResultsHeader.tsx b/ui/v2.5/src/components/List/ListResultsHeader.tsx index 091317ec8..8a1bba05e 100644 --- a/ui/v2.5/src/components/List/ListResultsHeader.tsx +++ b/ui/v2.5/src/components/List/ListResultsHeader.tsx @@ -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 ( -
- -
onChangeFilter(filter.setZoom(zoom))} />
+
+ onChangeFilter(filter.changePage(page))} + /> + +
+
); }; diff --git a/ui/v2.5/src/components/List/ListToolbar.tsx b/ui/v2.5/src/components/List/ListToolbar.tsx index 31ef7f7ee..25ee281c0 100644 --- a/ui/v2.5/src/components/List/ListToolbar.tsx +++ b/ui/v2.5/src/components/List/ListToolbar.tsx @@ -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<{
- onEditCriterion()} - count={criteria.length} - /> + + + + onEditCriterion()} + count={criteria.length} + /> + -
); @@ -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 ( -
- - {selected} selected - - +
+
+ + + {selected} selected + +
+ {operations} +
); } @@ -114,7 +131,11 @@ export const FilteredListToolbar2: React.FC<{ })} > {!hasSelection ? filterSection : selectionSection} -
{operationSection}
+ {!hasSelection ? ( +
+ {operationSection} +
+ ) : null} ); }; diff --git a/ui/v2.5/src/components/List/Pagination.tsx b/ui/v2.5/src/components/List/Pagination.tsx index e117b532e..bfa6697ee 100644 --- a/ui/v2.5/src/components/List/Pagination.tsx +++ b/ui/v2.5/src/components/List/Pagination.tsx @@ -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 = []; diff --git a/ui/v2.5/src/components/List/SavedFilterList.tsx b/ui/v2.5/src/components/List/SavedFilterList.tsx index 7e03404d2..83c6d8a65 100644 --- a/ui/v2.5/src/components/List/SavedFilterList.tsx +++ b/ui/v2.5/src/components/List/SavedFilterList.tsx @@ -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 = ({ @@ -841,8 +843,15 @@ export const SavedFilterDropdown: React.FC = (props) => { )); SavedFilterDropdownRef.displayName = "SavedFilterDropdown"; + const menu = ( + + ); + return ( - + = (props) => { - + {props.menuPortalTarget + ? createPortal(menu, props.menuPortalTarget) + : menu} ); }; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 7b02fd509..df50430a2 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -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; diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index c52fcc8d3..b9cb125f4 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -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 | undefined, + loc2: ReturnType +) { + return loc1 && loc1.pathname === loc2.pathname && loc1.search === loc2.search; +} + export function useFilterURL( filter: ListFilterModel, setFilter: React.Dispatch>, @@ -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) { diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index 03530c52e..ab584e90d 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -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 = PatchComponent( return; } - history.goBack(); + goBackOrReplace(history, "/performers"); } function toggleEditing(value?: boolean) { diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index c4088654a..7d326b3cd 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -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> = ({ ) { loadScene(queueScenes[currentQueueIndex + 1].id); } else { - history.goBack(); + goBackOrReplace(history, "/scenes"); } } diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 1154f384e..3936be6f2 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -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" /> )} } @@ -302,6 +310,7 @@ const SidebarContent: React.FC<{ filter={filter} setFilter={setFilter} filterHook={filterHook} + sectionID="tags" /> } @@ -309,6 +318,7 @@ const SidebarContent: React.FC<{ option={RatingCriterionOption} filter={filter} setFilter={setFilter} + sectionID="rating" /> } @@ -316,6 +326,7 @@ const SidebarContent: React.FC<{ option={OrganizedCriterionOption} filter={filter} setFilter={setFilter} + sectionID="organized" /> @@ -355,7 +366,7 @@ const SceneListOperations: React.FC<{ const intl = useIntl(); return ( -
+
{!!items && (