{onSetZoom &&
zoomIndex !== undefined &&
- displayMode === DisplayMode.Grid ? (
+ (displayMode === DisplayMode.Grid ||
+ displayMode === DisplayMode.Wall) ? (
@@ -149,7 +159,7 @@ export const ListViewOptions: React.FC = ({
onSetDisplayMode(option);
}}
>
- {getLabel(option)}
+ {getLabel(intl, option)}
))}
@@ -160,3 +170,48 @@ export const ListViewOptions: React.FC
= ({
>
);
};
+
+export const ListViewButtonGroup: React.FC = ({
+ zoomIndex,
+ onSetZoom,
+ displayMode,
+ onSetDisplayMode,
+ displayModeOptions,
+}) => {
+ const intl = useIntl();
+
+ return (
+ <>
+ {displayModeOptions.length > 1 && (
+
+ {displayModeOptions.map((option) => (
+
+ {getLabel(intl, option)}
+
+ }
+ >
+
+
+ ))}
+
+ )}
+
+ {onSetZoom &&
+ zoomIndex !== undefined &&
+ (displayMode === DisplayMode.Grid ||
+ displayMode === DisplayMode.Wall) ? (
+
+ ) : 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 cbeeaa70a..df1d6136a 100644
--- a/ui/v2.5/src/components/List/SavedFilterList.tsx
+++ b/ui/v2.5/src/components/List/SavedFilterList.tsx
@@ -30,12 +30,15 @@ import { faBookmark, faSave, faTimes } from "@fortawesome/free-solid-svg-icons";
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;
- setName: (name: string) => void;
- existing: { name: string; id: string }[];
-}> = ({ name, setName, existing }) => {
+ onSelect: (value: SavedFilterDataFragment) => void;
+ savedFilters: SavedFilterDataFragment[];
+ disabled?: boolean;
+}> = ({ name, onSelect, savedFilters: existing, disabled = false }) => {
const filtered = useMemo(() => {
if (!name) return existing;
@@ -51,7 +54,8 @@ const ExistingSavedFilterList: React.FC<{
@@ -64,7 +68,8 @@ const ExistingSavedFilterList: React.FC<{
export const SaveFilterDialog: React.FC<{
mode: FilterMode;
onClose: (name?: string, id?: string) => void;
-}> = ({ mode, onClose }) => {
+ isSaving?: boolean;
+}> = ({ mode, onClose, isSaving = false }) => {
const intl = useIntl();
const [filterName, setFilterName] = useState("");
@@ -79,6 +84,74 @@ export const SaveFilterDialog: React.FC<{
return (
+
+
+
+
+
+
+
+
+ setFilterName(e.target.value)}
+ disabled={isSaving}
+ />
+
+
+ setFilterName(f.name)}
+ savedFilters={data?.findSavedFilters ?? []}
+ />
+
+ {!!overwritingFilter && (
+
+
+
+ )}
+
+
+
+ onClose(filterName, overwritingFilter?.id)}
+ >
+ {intl.formatMessage({ id: "actions.save" })}
+
+
+
+ );
+};
+
+export const LoadFilterDialog: React.FC<{
+ mode: FilterMode;
+ onClose: (filter?: SavedFilterDataFragment) => void;
+}> = ({ mode, onClose }) => {
+ const intl = useIntl();
+ const [filterName, setFilterName] = useState("");
+
+ const { data } = useFindSavedFilters(mode);
+
+ return (
+
+
+
+
@@ -94,31 +167,14 @@ export const SaveFilterDialog: React.FC<{
onClose(f)}
+ savedFilters={data?.findSavedFilters ?? []}
/>
-
- {!!overwritingFilter && (
-
-
-
- )}
-
);
@@ -166,7 +222,7 @@ const OverwriteAlert: React.FC<{
void;
view?: View;
+ menuPortalTarget?: Element | DocumentFragment;
}
export const SavedFilterList: React.FC = ({
@@ -786,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/ZoomSlider.tsx b/ui/v2.5/src/components/List/ZoomSlider.tsx
index dff8e4f57..093b5ec7a 100644
--- a/ui/v2.5/src/components/List/ZoomSlider.tsx
+++ b/ui/v2.5/src/components/List/ZoomSlider.tsx
@@ -2,19 +2,14 @@ import React, { useEffect } from "react";
import Mousetrap from "mousetrap";
import { Form } from "react-bootstrap";
-export interface IZoomSelectProps {
- minZoom: number;
- maxZoom: number;
- zoomIndex: number;
- onChangeZoom: (v: number) => void;
-}
+const minZoom = 0;
+const maxZoom = 3;
-export const ZoomSelect: React.FC = ({
- minZoom,
- maxZoom,
- zoomIndex,
- onChangeZoom,
-}) => {
+export function useZoomKeybinds(props: {
+ zoomIndex: number | undefined;
+ onChangeZoom: (v: number) => void;
+}) {
+ const { zoomIndex, onChangeZoom } = props;
useEffect(() => {
Mousetrap.bind("+", () => {
if (zoomIndex !== undefined && zoomIndex < maxZoom) {
@@ -32,7 +27,17 @@ export const ZoomSelect: React.FC = ({
Mousetrap.unbind("-");
};
});
+}
+export interface IZoomSelectProps {
+ zoomIndex: number;
+ onChangeZoom: (v: number) => void;
+}
+
+export const ZoomSelect: React.FC = ({
+ zoomIndex,
+ onChangeZoom,
+}) => {
return (
div > :not(:first-child) {
+ margin-left: 0.25rem;
+ }
+ }
+
+ .search-term-row {
+ align-items: center;
+ display: flex;
+ gap: 0.5rem;
+ justify-content: space-between;
+ margin-bottom: 0.5rem;
+ margin-left: 1.5rem;
+ margin-right: 1rem;
+
+ .search-term-input {
+ flex-basis: 75%;
+ }
+
+ @include media-breakpoint-down(xs) {
+ flex-wrap: wrap;
+
+ > span {
+ width: 100%;
+ }
+
+ .search-term-input {
+ flex-basis: 100%;
+ }
+ }
+ }
+
.filter-tags {
border-top: 1px solid rgb(16 22 26 / 40%);
padding: 1rem 1rem 0 1rem;
@@ -412,11 +454,22 @@ input[type="range"].zoom-slider {
}
}
-.filter-tags .clear-all-button {
- color: $text-color;
- // to match filter pills
- line-height: 16px;
- padding: 0;
+.filter-tags {
+ display: flex;
+ justify-content: center;
+ margin-bottom: 0.5rem;
+
+ .more-tags {
+ background-color: transparent;
+ color: #fff;
+ }
+
+ .clear-all-button {
+ color: $text-color;
+ // to match filter pills
+ line-height: 16px;
+ padding: 0;
+ }
}
.filter-button {
@@ -695,7 +748,7 @@ input[type="range"].zoom-slider {
background-color: #202b33;
position: sticky;
top: 0;
- z-index: 100;
+ z-index: 1;
}
td:first-child {
@@ -864,6 +917,8 @@ input[type="range"].zoom-slider {
}
.filtered-list-toolbar {
+ align-items: center;
+ gap: 0.5rem;
justify-content: center;
margin-bottom: 0.5rem;
@@ -881,8 +936,10 @@ input[type="range"].zoom-slider {
}
}
- .btn.display-mode-select {
- margin-left: 0.5rem;
+ // set the width of the zoom-slider-container to prevent buttons moving when
+ // the slider appears/disappears
+ .zoom-slider-container {
+ min-width: 60px;
}
}
@@ -894,10 +951,6 @@ input[type="range"].zoom-slider {
}
}
-.search-term-input {
- margin-right: 0.5rem;
-}
-
.custom-field-filter {
align-items: center;
display: flex;
@@ -929,40 +982,454 @@ input[type="range"].zoom-slider {
}
.sidebar {
+ // make controls slightly larger on mobile
+ @include media-breakpoint-down(xs) {
+ .btn,
+ .form-control {
+ font-size: 1.25rem;
+ }
+ }
+
.sidebar-search-container {
display: flex;
margin-bottom: 0.5rem;
- margin-top: 0.25rem;
}
.search-term-input {
flex-grow: 1;
- margin-right: 0.25rem;
+ margin-right: 0;
.clearable-text-field {
height: 100%;
}
}
+
+ .edit-filter-button {
+ width: 100%;
+ }
+
+ .sidebar-footer {
+ background-color: $body-bg;
+ bottom: 0;
+ display: none;
+ padding: 0.5rem;
+ position: sticky;
+
+ @include media-breakpoint-down(xs) {
+ display: flex;
+ justify-content: center;
+ }
+ }
}
@include media-breakpoint-down(xs) {
- .sidebar .search-term-input {
- margin-right: 0.5rem;
+ .sidebar .sidebar-search-container {
+ margin-top: 0.25rem;
}
}
.pagination-footer {
- background-color: $body-bg;
+ background-color: transparent;
bottom: $navbar-height;
- padding: 0.5rem 1rem;
+ margin: auto;
+ padding: 0.5rem 1rem 0.75rem;
position: sticky;
+ width: fit-content;
z-index: 10;
@include media-breakpoint-up(sm) {
bottom: 0;
}
+ .pagination.btn-group {
+ box-shadow: 0 8px 10px 2px rgb(0 0 0 / 30%);
+ }
+
.pagination {
margin-bottom: 0;
+
+ .btn:disabled {
+ color: #888;
+ opacity: 1;
+ }
}
}
+
+// hide sidebar Edit Filter button on larger screens
+@include media-breakpoint-up(md) {
+ .sidebar .edit-filter-button {
+ display: none;
+ }
+}
+
+// the following refers to the new FilteredListToolbar2 component
+// ensure the rules here don't conflict with the original filtered-list-toolbar above
+// TODO - replace with only .filtered-list-toolbar once all lists use the new toolbar
+.scene-list-toolbar {
+ &.filtered-list-toolbar {
+ align-items: center;
+ background-color: $body-bg;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ margin-bottom: 0;
+ row-gap: 1rem;
+
+ > div {
+ align-items: center;
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-start;
+
+ &:last-child {
+ flex-shrink: 0;
+ justify-content: flex-end;
+ }
+ }
+ }
+
+ &.filtered-list-toolbar {
+ flex-wrap: nowrap;
+ gap: 1rem;
+ // offset the main padding
+ margin-top: -0.5rem;
+ padding-bottom: 0.5rem;
+ padding-top: 0.5rem;
+ position: sticky;
+ top: $navbar-height;
+ z-index: 10;
+
+ @include media-breakpoint-down(xs) {
+ top: 0;
+ }
+
+ // hide drop down menu items for play and create new
+ // when the buttons are visible
+ @include media-breakpoint-up(sm) {
+ .scene-list-operations {
+ .play-item,
+ .create-new-item {
+ display: none;
+ }
+ }
+ }
+
+ // hide play and create new buttons on xs screens
+ // show these in the drop down menu instead
+ @include media-breakpoint-down(xs) {
+ .play-button,
+ .create-new-button {
+ display: none;
+ }
+ }
+
+ .toolbar-selection-section,
+ div.filter-section {
+ border: 1px solid $secondary;
+ border-radius: 0.25rem;
+ flex-grow: 1;
+ overflow-x: hidden;
+ }
+
+ 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: flex;
+ margin-right: -0.5rem;
+ min-width: calc($sidebar-width - 15px);
+ padding-right: 10px;
+
+ .search-term-input {
+ margin-right: 0;
+ width: 100%;
+
+ .clearable-text-field {
+ height: 100%;
+ }
+ }
+ }
+
+ .filter-tags {
+ flex-grow: 1;
+ flex-wrap: nowrap;
+ justify-content: flex-start;
+ margin-bottom: 0;
+
+ // account for filter button, and toggle sidebar buttons with gaps
+ width: calc(100% - 70px - 1rem);
+
+ @include media-breakpoint-down(xs) {
+ overflow-x: auto;
+ scrollbar-width: thin;
+ }
+
+ .tag-item {
+ white-space: nowrap;
+ }
+ }
+ }
+}
+
+// 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 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
+ .sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar .filter-tags {
+ width: calc(100% - 35px - 0.5rem);
+ }
+}
+
+// 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;
+ }
+
+ .filter-tags {
+ order: 2;
+ }
+ }
+}
+
+// hide the search term tag item when the search box is visible
+@include media-breakpoint-up(lg) {
+ // TODO - remove scene-list-toolbar when all lists use the new toolbar
+ .scene-list-toolbar.filtered-list-toolbar
+ .filter-tags
+ .search-term-filter-tag {
+ display: none;
+ }
+}
+@include media-breakpoint-down(md) {
+ // TODO - remove scene-list-toolbar when all lists use the new toolbar
+ .sidebar-pane:not(.hide-sidebar)
+ .scene-list-toolbar.filtered-list-toolbar
+ .filter-tags
+ .search-term-filter-tag {
+ display: none;
+ }
+}
+
+// TODO - remove scene-list-toolbar when all lists use the new toolbar
+.detail-body .scene-list-toolbar.filtered-list-toolbar {
+ top: calc($sticky-detail-header-height + $navbar-height);
+
+ @include media-breakpoint-down(xs) {
+ top: 0;
+ }
+}
+
+#more-criteria-popover {
+ box-shadow: 0 8px 10px 2px rgb(0 0 0 / 30%);
+ max-width: 400px;
+ padding: 0.25rem;
+}
+
+.list-results-header {
+ align-items: flex-start;
+ background-color: $body-bg;
+ display: flex;
+
+ > 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;
+ }
+ }
+}
+
+.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.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;
+ margin-right: auto;
+ }
+ }
+}
+
+// Duration slider styles
+.duration-slider,
+.age-slider-container {
+ padding: 0.5rem 0 1rem;
+ width: 100%;
+}
+
+.duration-label-input,
+.age-label-input {
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 0.25rem;
+ color: $text-color;
+ font-size: 0.875rem;
+ font-weight: 500;
+ padding: 0.125rem 0.25rem;
+ width: 4rem;
+
+ &:hover {
+ border-color: $secondary;
+ }
+
+ &:focus {
+ border-color: $primary;
+ outline: none;
+ }
+}
+
+.duration-preset {
+ cursor: pointer;
+}
diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts
index bb85145e7..c15c3335a 100644
--- a/ui/v2.5/src/components/List/util.ts
+++ b/ui/v2.5/src/components/List/util.ts
@@ -1,17 +1,24 @@
-import { useCallback, useContext, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import Mousetrap from "mousetrap";
import { ListFilterModel } from "src/models/list-filter/filter";
import { useHistory, useLocation } from "react-router-dom";
import { isEqual, isFunction } from "lodash-es";
import { QueryResult } from "@apollo/client";
import { IHasID } from "src/utils/data";
-import { ConfigurationContext } from "src/hooks/Config";
+import { useConfigurationContext } from "src/hooks/Config";
import { View } from "./views";
import { usePrevious } from "src/hooks/state";
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>,
@@ -24,6 +31,7 @@ export function useFilterURL(
const history = useHistory();
const location = useLocation();
+ const prevLocation = usePrevious(location);
// when the filter changes, update the URL
const updateFilter = useCallback(
@@ -47,7 +55,8 @@ export function useFilterURL(
// and updates the filter accordingly.
useEffect(() => {
// don't apply if active is false
- if (!active) return;
+ // also don't apply if location is unchanged
+ if (!active || locationEquals(prevLocation, location)) return;
// re-init to load default filter on empty new query params
if (!location.search) {
@@ -73,7 +82,8 @@ export function useFilterURL(
});
}, [
active,
- location.search,
+ prevLocation,
+ location,
defaultFilter,
setFilter,
updateFilter,
@@ -84,7 +94,7 @@ export function useFilterURL(
}
export function useDefaultFilter(emptyFilter: ListFilterModel, view?: View) {
- const { configuration: config, loading } = useContext(ConfigurationContext);
+ const { configuration: config } = useConfigurationContext();
const defaultFilter = useMemo(() => {
if (view && config?.ui.defaultFilters?.[view]) {
@@ -104,9 +114,9 @@ export function useDefaultFilter(emptyFilter: ListFilterModel, view?: View) {
}
}, [view, config?.ui.defaultFilters, emptyFilter]);
- const retFilter = loading ? undefined : defaultFilter ?? emptyFilter;
+ const retFilter = defaultFilter ?? emptyFilter;
- return { defaultFilter: retFilter, loading };
+ return { defaultFilter: retFilter };
}
function useEmptyFilter(props: {
@@ -148,14 +158,14 @@ export function useFilterState(
const emptyFilter = useEmptyFilter({ filterMode, defaultSort, config });
- const { defaultFilter, loading } = useDefaultFilter(emptyFilter, view);
+ const { defaultFilter } = useDefaultFilter(emptyFilter, view);
const { setFilter } = useFilterURL(filter, setFilterState, {
defaultFilter,
active: useURL,
});
- return { loading, filter, setFilter };
+ return { filter, setFilter };
}
export function useFilterOperations(props: {
@@ -196,9 +206,12 @@ export function useFilterOperations(props: {
[setFilter]
);
- const clearAllCriteria = useCallback(() => {
- setFilter((cv) => cv.clearCriteria());
- }, [setFilter]);
+ const clearAllCriteria = useCallback(
+ (includeSearchTerm = false) => {
+ setFilter((cv) => cv.clearCriteria(includeSearchTerm));
+ },
+ [setFilter]
+ );
return {
setPage,
diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx
index 98bbc26c6..caee46f0c 100644
--- a/ui/v2.5/src/components/MainNavbar.tsx
+++ b/ui/v2.5/src/components/MainNavbar.tsx
@@ -11,7 +11,7 @@ import {
MessageDescriptor,
useIntl,
} from "react-intl";
-import { Nav, Navbar, Button, Fade } from "react-bootstrap";
+import { Nav, Navbar, Button } from "react-bootstrap";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { LinkContainer } from "react-router-bootstrap";
import { Link, NavLink, useLocation, useHistory } from "react-router-dom";
@@ -19,7 +19,7 @@ import Mousetrap from "mousetrap";
import SessionUtils from "src/utils/session";
import { Icon } from "src/components/Shared/Icon";
-import { ConfigurationContext } from "src/hooks/Config";
+import { useConfigurationContext } from "src/hooks/Config";
import { ManualStateContext } from "./Help/context";
import { SettingsButton } from "./SettingsButton";
import {
@@ -103,7 +103,6 @@ const allMenuItems: IMenuItem[] = [
href: "/scenes",
icon: faPlayCircle,
hotkey: "g s",
- userCreatable: true,
},
{
name: "images",
@@ -182,7 +181,7 @@ const MainNavbarUtilityItems = PatchComponent(
export const MainNavbar: React.FC = () => {
const history = useHistory();
const location = useLocation();
- const { configuration, loading } = React.useContext(ConfigurationContext);
+ const { configuration } = useConfigurationContext();
const { openManual } = React.useContext(ManualStateContext);
const [expanded, setExpanded] = useState(false);
@@ -360,35 +359,31 @@ export const MainNavbar: React.FC = () => {
ref={navbarRef}
>
-
- <>
-
- {menuItems.map(({ href, icon, message }) => (
-
-
-
-
-
- ))}
-
-
- >
-
+
+ {menuItems.map(({ href, icon, message }) => (
+
+
+
+
+
+ ))}
+
+
diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx
index 71fcbedd9..677ac3aa1 100644
--- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx
+++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx
@@ -27,6 +27,8 @@ import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import * as FormUtils from "src/utils/form";
import { CountrySelect } from "../Shared/CountrySelect";
+import { useConfigurationContext } from "src/hooks/Config";
+import cx from "classnames";
interface IListOperationProps {
selected: GQL.SlimPerformerDataFragment[];
@@ -61,6 +63,10 @@ export const EditPerformersDialog: React.FC = (
) => {
const intl = useIntl();
const Toast = useToast();
+
+ const { configuration } = useConfigurationContext();
+ const { sfwContentMode } = configuration.interface;
+
const [tagIds, setTagIds] = useState({
mode: GQL.BulkUpdateIdMode.Add,
});
@@ -204,7 +210,7 @@ export const EditPerformersDialog: React.FC = (
setter: (newValue: string | undefined) => void
) {
return (
-
+
@@ -218,9 +224,13 @@ export const EditPerformersDialog: React.FC = (
}
function render() {
+ // sfw class needs to be set because it is outside body
+
return (
= (
}}
isRunning={isUpdating}
>
-
+
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
@@ -322,7 +332,7 @@ export const EditPerformersDialog: React.FC = (
setPenisLength(v)
)}
-
+
diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx
index 02e2a68fd..5f7a26d42 100644
--- a/ui/v2.5/src/components/Performers/PerformerCard.tsx
+++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx
@@ -6,7 +6,6 @@ import NavUtils from "src/utils/navigation";
import TextUtils from "src/utils/text";
import { GridCard } from "../Shared/GridCard/GridCard";
import { CountryFlag } from "../Shared/CountryFlag";
-import { SweatDrops } from "../Shared/SweatDrops";
import { HoverPopover } from "../Shared/HoverPopover";
import { Icon } from "../Shared/Icon";
import { TagLink } from "../Shared/TagLink";
@@ -17,12 +16,16 @@ import {
} from "src/models/list-filter/criteria/criterion";
import { PopoverCountButton } from "../Shared/PopoverCountButton";
import GenderIcon from "./GenderIcon";
-import { faTag } from "@fortawesome/free-solid-svg-icons";
+import { faLink, faTag } from "@fortawesome/free-solid-svg-icons";
+import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons";
import { RatingBanner } from "../Shared/RatingBanner";
import { usePerformerUpdate } from "src/core/StashService";
import { ILabeledId } from "src/models/list-filter/types";
import { FavoriteIcon } from "../Shared/FavoriteIcon";
import { PatchComponent } from "src/patch";
+import { ExternalLinksButton } from "../Shared/ExternalLinksButton";
+import { useConfigurationContext } from "src/hooks/Config";
+import { OCounterButton } from "../Shared/CountButton";
export interface IPerformerCardExtraCriteria {
scenes?: ModifierCriterion[];
@@ -100,16 +103,7 @@ const PerformerCardPopovers: React.FC = PatchComponent(
function maybeRenderOCounter() {
if (!performer.o_counter) return;
- return (
-
-
-
- );
+ return ;
}
function maybeRenderTagPopoverButton() {
@@ -176,6 +170,8 @@ const PerformerCardPopovers: React.FC = PatchComponent(
const PerformerCardOverlays: React.FC = PatchComponent(
"PerformerCard.Overlays",
({ performer }) => {
+ const { configuration } = useConfigurationContext();
+ const uiConfig = configuration?.ui;
const [updatePerformer] = usePerformerUpdate();
function onToggleFavorite(v: boolean) {
@@ -215,6 +211,63 @@ const PerformerCardOverlays: React.FC = PatchComponent(
}
}
+ function maybeRenderLinks() {
+ if (!uiConfig?.showLinksOnPerformerCard) {
+ return;
+ }
+
+ if (performer.urls && performer.urls.length > 0) {
+ const twitter = performer.urls.filter((u) =>
+ u.match(/https?:\/\/(?:www\.)?(?:twitter|x).com\//)
+ );
+ const instagram = performer.urls.filter((u) =>
+ u.match(/https?:\/\/(?:www\.)?instagram.com\//)
+ );
+ const others = performer.urls.filter(
+ (u) => !twitter.includes(u) && !instagram.includes(u)
+ );
+
+ return (
+
+ {twitter.length > 0 && (
+
+ )}
+ {instagram.length > 0 && (
+
+ )}
+ {others.length > 0 && (
+
+ )}
+
+ );
+ }
+ }
+
return (
<>
= PatchComponent(
className="hide-not-favorite"
/>
{maybeRenderRatingBanner()}
+ {maybeRenderLinks()}
{maybeRenderFlag()}
>
);
diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx
index 03530c52e..dd72d0025 100644
--- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx
+++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx
@@ -16,7 +16,7 @@ import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { ErrorMessage } from "src/components/Shared/ErrorMessage";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { useToast } from "src/hooks/Toast";
-import { ConfigurationContext } from "src/hooks/Config";
+import { useConfigurationContext } from "src/hooks/Config";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import {
CompressedPerformerDetailsPanel,
@@ -47,6 +47,8 @@ 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";
+import { OCounterButton } from "src/components/Shared/CountButton";
interface IProps {
performer: GQL.PerformerDataFragment;
@@ -238,7 +240,7 @@ const PerformerPage: React.FC = PatchComponent(
const intl = useIntl();
// Configuration settings
- const { configuration } = React.useContext(ConfigurationContext);
+ const { configuration } = useConfigurationContext();
const uiConfig = configuration?.ui;
const abbreviateCounter = uiConfig?.abbreviateCounters ?? false;
const enableBackgroundImage =
@@ -330,7 +332,7 @@ const PerformerPage: React.FC = PatchComponent(
return;
}
- history.goBack();
+ goBackOrReplace(history, "/performers");
}
function toggleEditing(value?: boolean) {
@@ -422,12 +424,17 @@ const PerformerPage: React.FC = PatchComponent(
- setRating(value)}
- clickToRate
- withoutContext
- />
+
+ setRating(value)}
+ clickToRate
+ withoutContext
+ />
+ {!!performer.o_counter && (
+
+ )}
+
{!isEditing && (
=
}
fullWidth={fullWidth}
/>
-
+
{performer.country ? (
= ({
const [scrapedPerformer, setScrapedPerformer] =
useState();
- const { configuration: stashConfig } = React.useContext(ConfigurationContext);
+ const { configuration: stashConfig } = useConfigurationContext();
const intl = useIntl();
@@ -466,7 +466,6 @@ export const PerformerEditPanel: React.FC = ({
setScraper(undefined);
} else {
setScrapedPerformer(result);
- updateStashIDs(performerResult.remote_site_id);
}
}
diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx
index eb5f26a83..ad7e44d6d 100644
--- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx
+++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx
@@ -63,6 +63,7 @@ function renderScrapedGenderRow(
) {
return (
renderScrapedGender(result)}
@@ -113,6 +114,7 @@ function renderScrapedCircumcisedRow(
return (
renderScrapedCircumcised(result)}
renderNewField={() =>
@@ -146,6 +148,22 @@ export const PerformerScrapeDialog: React.FC = (
return;
}
+ // #6257 - it is possible (though unsupported) to have multiple stash IDs for the same
+ // endpoint; in that case, we should prefer the one matching the scraped remote site ID
+ // if it exists
+ const stashIDs = (props.performer.stash_ids ?? []).filter(
+ (s) => s.endpoint === endpoint
+ );
+ if (stashIDs.length > 1 && props.scraped.remote_site_id) {
+ const matchingID = stashIDs.find(
+ (s) => s.stash_id === props.scraped.remote_site_id
+ );
+ if (matchingID) {
+ return matchingID.stash_id;
+ }
+ }
+
+ // otherwise, return the first stash ID for the endpoint
return props.performer.stash_ids?.find((s) => s.endpoint === endpoint)
?.stash_id;
}
@@ -385,16 +403,19 @@ export const PerformerScrapeDialog: React.FC = (
return (
<>
setName(value)}
/>
setDisambiguation(value)}
/>
setAliases(value)}
@@ -405,46 +426,55 @@ export const PerformerScrapeDialog: React.FC = (
(value) => setGender(value)
)}
setBirthdate(value)}
/>
setDeathDate(value)}
/>
setEthnicity(value)}
/>
setCountry(value)}
/>
setHairColor(value)}
/>
setEyeColor(value)}
/>
setWeight(value)}
/>
setHeight(value)}
/>
setPenisLength(value)}
@@ -455,42 +485,50 @@ export const PerformerScrapeDialog: React.FC = (
(value) => setCircumcised(value)
)}
setMeasurements(value)}
/>
setFakeTits(value)}
/>
setCareerLength(value)}
/>
setTattoos(value)}
/>
setPiercings(value)}
/>
setURLs(value)}
/>
setDetails(value)}
/>
{scrapedTagsRow}
= (
onChange={(value) => setImage(value)}
/>
= ({
selectable
>
= ({
placement = "top",
target,
}) => {
- const { configuration: config } = React.useContext(ConfigurationContext);
+ const { configuration: config } = useConfigurationContext();
const showPerformerCardOnHover = config?.ui.showTagCardOnHover ?? true;
diff --git a/ui/v2.5/src/components/Performers/PerformerSelect.tsx b/ui/v2.5/src/components/Performers/PerformerSelect.tsx
index d31dc3ec7..f10519897 100644
--- a/ui/v2.5/src/components/Performers/PerformerSelect.tsx
+++ b/ui/v2.5/src/components/Performers/PerformerSelect.tsx
@@ -13,7 +13,7 @@ import {
queryFindPerformersByIDForSelect,
queryFindPerformersForSelect,
} from "src/core/StashService";
-import { ConfigurationContext } from "src/hooks/Config";
+import { useConfigurationContext } from "src/hooks/Config";
import { useIntl } from "react-intl";
import { defaultMaxOptionsShown } from "src/core/config";
import { ListFilterModel } from "src/models/list-filter/filter";
@@ -82,12 +82,12 @@ const _PerformerSelect: React.FC<
> = (props) => {
const [createPerformer] = usePerformerCreate();
- const { configuration } = React.useContext(ConfigurationContext);
+ const { configuration } = useConfigurationContext();
const intl = useIntl();
const maxOptionsShown =
configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown;
const defaultCreatable =
- !configuration?.interface.disableDropdownCreate.performer ?? true;
+ !configuration?.interface.disableDropdownCreate.performer;
async function loadPerformers(input: string): Promise