diff --git a/ui/v2.5/src/components/List/FilterTags.tsx b/ui/v2.5/src/components/List/FilterTags.tsx index a384f05ca..b690f8781 100644 --- a/ui/v2.5/src/components/List/FilterTags.tsx +++ b/ui/v2.5/src/components/List/FilterTags.tsx @@ -101,8 +101,12 @@ export const FilterTags: React.FC = ({ ); } + if (criteria.length === 0) { + return null; + } + return ( -
+
{criteria.map(renderFilterTags)} {criteria.length >= 3 && ( +
+ } diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index 4933c7e75..99bae365e 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -36,6 +36,7 @@ import { useDebounce } from "src/hooks/debounce"; import { View } from "./views"; import { ClearableInput } from "../Shared/ClearableInput"; import { useStopWheelScroll } from "src/utils/form"; +import { ISortByOption } from "src/models/list-filter/filter-options"; export function useDebouncedSearchInput( filter: ListFilterModel, @@ -230,6 +231,94 @@ export const PageSizeSelector: React.FC<{ ); }; +export const SortBySelect: React.FC<{ + className?: string; + sortBy: string | undefined; + sortDirection: SortDirectionEnum; + options: ISortByOption[]; + onChangeSortBy: (eventKey: string | null) => void; + onChangeSortDirection: () => void; + onReshuffleRandomSort: () => void; +}> = ({ + className, + sortBy, + sortDirection, + options, + onChangeSortBy, + onChangeSortDirection, + onReshuffleRandomSort, +}) => { + const intl = useIntl(); + + const currentSortBy = options.find((o) => o.value === sortBy); + + function renderSortByOptions() { + return options + .map((o) => { + return { + message: intl.formatMessage({ id: o.messageID }), + value: o.value, + }; + }) + .sort((a, b) => a.message.localeCompare(b.message)) + .map((option) => ( + + {option.message} + + )); + } + + return ( + + + + {currentSortBy + ? intl.formatMessage({ id: currentSortBy.messageID }) + : ""} + + + + {renderSortByOptions()} + + + {sortDirection === SortDirectionEnum.Asc + ? intl.formatMessage({ id: "ascending" }) + : intl.formatMessage({ id: "descending" })} + + } + > + + + {sortBy === "random" && ( + + {intl.formatMessage({ id: "actions.reshuffle" })} + + } + > + + + )} + + ); +}; + interface IListFilterProps { onFilterUpdate: (newFilter: ListFilterModel) => void; filter: ListFilterModel; @@ -247,8 +336,6 @@ export const ListFilter: React.FC = ({ }) => { const filterOptions = filter.options; - const intl = useIntl(); - useEffect(() => { Mousetrap.bind("r", () => onReshuffleRandomSort()); @@ -289,32 +376,7 @@ export const ListFilter: React.FC = ({ onFilterUpdate(newFilter); } - function renderSortByOptions() { - return filterOptions.sortByOptions - .map((o) => { - return { - message: intl.formatMessage({ id: o.messageID }), - value: o.value, - }; - }) - .sort((a, b) => a.message.localeCompare(b.message)) - .map((option) => ( - - {option.message} - - )); - } - function render() { - const currentSortBy = filterOptions.sortByOptions.find( - (o) => o.value === filter.sortBy - ); - return ( <> {!withSidebar && ( @@ -342,56 +404,21 @@ export const ListFilter: React.FC = ({ > openFilterDialog()} - filter={filter} + count={filter.count()} /> )} - - - - {currentSortBy - ? intl.formatMessage({ id: currentSortBy.messageID }) - : ""} - - - - {renderSortByOptions()} - - - {filter.sortDirection === SortDirectionEnum.Asc - ? intl.formatMessage({ id: "ascending" }) - : intl.formatMessage({ id: "descending" })} - - } - > - - - {filter.sortBy === "random" && ( - - {intl.formatMessage({ id: "actions.reshuffle" })} - - } - > - - - )} - + > = ({ - children, -}) => { +export const OperationDropdown: React.FC< + PropsWithChildren<{ + className?: string; + }> +> = ({ className, children }) => { if (!children) return null; return ( - + @@ -33,6 +36,21 @@ export const OperationDropdown: React.FC> = ({ ); }; +export const OperationDropdownItem: React.FC<{ + text: string; + onClick: () => void; + className?: string; +}> = ({ text, onClick, className }) => { + return ( + + {text} + + ); +}; + export interface IListFilterOperation { text: string; onClick: () => void; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 9a719b4b2..49deb5983 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -412,6 +412,12 @@ input[type="range"].zoom-slider { } } +.filter-tags { + display: flex; + justify-content: center; + margin-bottom: 0.5rem; +} + .filter-tags .clear-all-button { color: $text-color; // to match filter pills @@ -929,25 +935,49 @@ 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; } } diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx index 98bbc26c6..59f8e51aa 100644 --- a/ui/v2.5/src/components/MainNavbar.tsx +++ b/ui/v2.5/src/components/MainNavbar.tsx @@ -103,7 +103,6 @@ const allMenuItems: IMenuItem[] = [ href: "/scenes", icon: faPlayCircle, hotkey: "g s", - userCreatable: true, }, { name: "images", diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 3f9bf6315..fd75b96be 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -19,7 +19,13 @@ import { SceneCardsGrid } from "./SceneCardsGrid"; import { TaggerContext } from "../Tagger/context"; import { IdentifyDialog } from "../Dialogs/IdentifyDialog/IdentifyDialog"; import { ConfigurationContext } from "src/hooks/Config"; -import { faPlay } from "@fortawesome/free-solid-svg-icons"; +import { + faPencil, + faPlay, + faPlus, + faTimes, + faTrash, +} from "@fortawesome/free-solid-svg-icons"; import { SceneMergeModal } from "./SceneMergeDialog"; import { objectTitle } from "src/core/files"; import TextUtils from "src/utils/text"; @@ -27,8 +33,10 @@ import { View } from "../List/views"; import { FileSize } from "../Shared/FileSize"; import { LoadedContent } from "../List/PagedList"; import { useCloseEditDelete, useFilterOperations } from "../List/util"; -import { IListFilterOperation } from "../List/ListOperationButtons"; -import { FilteredListToolbar } from "../List/FilteredListToolbar"; +import { + OperationDropdown, + OperationDropdownItem, +} from "../List/ListOperationButtons"; import { useFilteredItemList } from "../List/ItemList"; import { FilterTags } from "../List/FilterTags"; import { Sidebar, SidebarPane, useSidebarState } from "../Shared/Sidebar"; @@ -49,6 +57,11 @@ import { } from "../List/Filters/FilterSidebar"; import { PatchContainerComponent } from "src/patch"; import { Pagination, PaginationIndex } from "../List/Pagination"; +import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap"; +import { FilterButton } from "../List/Filters/FilterButton"; +import { Icon } from "../Shared/Icon"; +import { ListViewOptions } from "../List/ListViewOptions"; +import { PageSizeSelector, SortBySelect } from "../List/ListFilter"; function renderMetadataByline(result: GQL.FindScenesQueryResult) { const duration = result?.data?.findScenes?.duration; @@ -82,33 +95,51 @@ function renderMetadataByline(result: GQL.FindScenesQueryResult) { function usePlayScene() { const history = useHistory(); + const { configuration: config } = useContext(ConfigurationContext); + const cont = config?.interface.continuePlaylistDefault ?? false; + const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false; + const playScene = useCallback( - (queue: SceneQueue, sceneID: string, options: IPlaySceneOptions) => { - history.push(queue.makeLink(sceneID, options)); + (queue: SceneQueue, sceneID: string, options?: IPlaySceneOptions) => { + history.push( + queue.makeLink(sceneID, { autoPlay, continue: cont, ...options }) + ); }, - [history] + [history, cont, autoPlay] ); return playScene; } function usePlaySelected(selectedIds: Set) { - const { configuration: config } = useContext(ConfigurationContext); const playScene = usePlayScene(); const playSelected = useCallback(() => { // populate queue and go to first scene const sceneIDs = Array.from(selectedIds.values()); const queue = SceneQueue.fromSceneIDList(sceneIDs); - const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false; - playScene(queue, sceneIDs[0], { autoPlay }); - }, [selectedIds, config?.interface.autostartVideoOnPlaySelected, playScene]); + + playScene(queue, sceneIDs[0]); + }, [selectedIds, playScene]); return playSelected; } +function usePlayFirst() { + const playScene = usePlayScene(); + + const playFirst = useCallback( + (queue: SceneQueue, sceneID: string, index: number) => { + // populate queue and go to first scene + playScene(queue, sceneID, { sceneIndex: index }); + }, + [playScene] + ); + + return playFirst; +} + function usePlayRandom(filter: ListFilterModel, count: number) { - const { configuration: config } = useContext(ConfigurationContext); const playScene = usePlayScene(); const playRandom = useCallback(async () => { @@ -130,15 +161,9 @@ function usePlayRandom(filter: ListFilterModel, count: number) { if (scene) { // navigate to the image player page const queue = SceneQueue.fromListFilterModel(filterCopy); - const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false; - playScene(queue, scene.id, { sceneIndex: index, autoPlay }); + playScene(queue, scene.id, { sceneIndex: index }); } - }, [ - filter, - count, - config?.interface.autostartVideoOnPlaySelected, - playScene, - ]); + }, [filter, count, playScene]); return playRandom; } @@ -213,12 +238,23 @@ const SidebarContent: React.FC<{ sidebarOpen: boolean; onClose?: () => void; showEditFilter: (editingCriterion?: string) => void; -}> = ({ filter, setFilter, view, showEditFilter, sidebarOpen, onClose }) => { + count?: number; +}> = ({ + filter, + setFilter, + view, + showEditFilter, + sidebarOpen, + onClose, + count, +}) => { + const showResultsId = + count !== undefined ? "actions.show_count_results" : "actions.show_results"; + return ( <> + +
+ +
); }; +interface IOperations { + text: string; + onClick: () => void; + isDisplayed?: () => boolean; + className?: string; +} + +const ListToolbarContent: React.FC<{ + criteriaCount: number; + items: GQL.SlimSceneDataFragment[]; + selectedIds: Set; + operations: IOperations[]; + onToggleSidebar: () => void; + onSelectAll: () => void; + onSelectNone: () => void; + onEdit: () => void; + onDelete: () => void; + onPlay: () => void; + onCreateNew: () => void; +}> = ({ + criteriaCount, + items, + selectedIds, + operations, + onToggleSidebar, + onSelectAll, + onSelectNone, + onEdit, + onDelete, + onPlay, + onCreateNew, +}) => { + const intl = useIntl(); + + const hasSelection = selectedIds.size > 0; + + return ( + <> + {!hasSelection && ( +
+ onToggleSidebar()} + count={criteriaCount} + title={intl.formatMessage({ id: "actions.sidebar.toggle" })} + /> +
+ )} + {hasSelection && ( +
+ + {selectedIds.size} selected + +
+ )} +
+ + {!!items.length && ( + + )} + {!hasSelection && ( + + )} + + {hasSelection && ( + <> + + + + )} + + + {operations.map((o) => { + if (o.isDisplayed && !o.isDisplayed()) { + return null; + } + + return ( + + ); + })} + + +
+ + ); +}; + +const ListResultsHeader: React.FC<{ + loading: boolean; + filter: ListFilterModel; + totalCount: number; + metadataByline?: React.ReactNode; + onChangeFilter: (filter: ListFilterModel) => void; +}> = ({ loading, filter, totalCount, metadataByline, onChangeFilter }) => { + return ( + +
+ +
+
+ + onChangeFilter(filter.setSortBy(s ?? undefined)) + } + onChangeSortDirection={() => + onChangeFilter(filter.toggleSortDirection()) + } + onReshuffleRandomSort={() => + onChangeFilter(filter.reshuffleRandomSort()) + } + /> + onChangeFilter(filter.setPageSize(s))} + /> + + onChangeFilter(filter.setDisplayMode(mode)) + } + onSetZoom={(zoom) => onChangeFilter(filter.setZoom(zoom))} + /> +
+
+ ); +}; + interface IFilteredScenes { filterHook?: (filter: ListFilterModel) => ListFilterModel; defaultSort?: string; @@ -312,6 +531,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => { selectedIds, selectedItems, onSelectChange, + onSelectAll, onSelectNone, hasSelection, } = listSelect; @@ -337,13 +557,36 @@ export const FilteredSceneList = (props: IFilteredScenes) => { }); const metadataByline = useMemo(() => { - if (cachedResult.loading) return ""; + if (cachedResult.loading) return null; - return renderMetadataByline(cachedResult) ?? ""; + return renderMetadataByline(cachedResult) ?? null; }, [cachedResult]); - const playSelected = usePlaySelected(selectedIds); + const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]); + const playRandom = usePlayRandom(filter, totalCount); + const playSelected = usePlaySelected(selectedIds); + const playFirst = usePlayFirst(); + + function onCreateNew() { + history.push("/scenes/new"); + } + + function onPlay() { + if (items.length === 0) { + return; + } + + // if there are selected items, play those + if (hasSelection) { + playSelected(); + return; + } + + // otherwise, play the first item in the list + const sceneID = items[0].id; + playFirst(queue, sceneID, 0); + } function onExport(all: boolean) { showModal( @@ -381,16 +624,41 @@ export const FilteredSceneList = (props: IFilteredScenes) => { ); } - const otherOperations: IListFilterOperation[] = [ + function onEdit() { + showModal( + + ); + } + + function onDelete() { + showModal( + + ); + } + + const otherOperations = [ { - text: intl.formatMessage({ id: "actions.play_selected" }), - onClick: playSelected, - isDisplayed: () => hasSelection, - icon: faPlay, + text: intl.formatMessage({ id: "actions.play" }), + onClick: () => onPlay(), + isDisplayed: () => items.length > 0, + className: "play-item", + }, + { + text: intl.formatMessage( + { id: "actions.create_entity" }, + { entityType: intl.formatMessage({ id: "scene" }) } + ), + onClick: () => onCreateNew(), + isDisplayed: () => !hasSelection, + className: "create-new-item", }, { text: intl.formatMessage({ id: "actions.play_random" }), onClick: playRandom, + isDisplayed: () => totalCount > 1, }, { text: `${intl.formatMessage({ id: "actions.generate" })}…`, @@ -452,34 +720,36 @@ export const FilteredSceneList = (props: IFilteredScenes) => { view={view} sidebarOpen={showSidebar} onClose={() => setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} />
- + setShowSidebar(!showSidebar)} + onSelectAll={() => onSelectAll()} + onSelectNone={() => onSelectNone()} + onEdit={onEdit} + onDelete={onDelete} + onCreateNew={onCreateNew} + onPlay={onPlay} + /> + + + - showModal( - - ) - } - onDelete={() => { - showModal( - - ); - }} - operations={otherOperations} - onToggleSidebar={() => setShowSidebar((v) => !v)} - zoomable + totalCount={totalCount} + metadataByline={metadataByline} + onChangeFilter={(newFilter) => setFilter(newFilter)} /> { onRemoveAll={() => clearAllCriteria()} /> - - div { + align-items: center; + display: flex; + gap: 0.5rem; + justify-content: flex-start; + + &:last-child { + flex-shrink: 0; + justify-content: flex-end; + margin-left: auto; + } + } +} + +.scene-list-toolbar { + flex-wrap: wrap; + // 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; + } + + .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) { + .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; + } + } +} + +.scene-list-header { + flex-wrap: wrap-reverse; + gap: 0.5rem; + margin-bottom: 0.5rem; + + .paginationIndex { + margin: 0; + } + + // center the header on smaller screens + @include media-breakpoint-down(sm) { + & > div, + & > div:last-child { + flex-basis: 100%; + justify-content: center; + margin-left: auto; + margin-right: auto; + } + } +} + +.detail-body .scene-list-toolbar { + top: calc($sticky-detail-header-height + $navbar-height); + + @include media-breakpoint-down(xs) { + top: 0; + } +} diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index c80c92fdd..b3726cd4f 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -774,8 +774,9 @@ $sidebar-width: 250px; .sidebar { bottom: 0; left: 0; - margin-top: 4rem; + margin-top: $navbar-height; overflow-y: auto; + padding-top: 0.5rem; position: fixed; scrollbar-gutter: stable; top: 0; @@ -890,8 +891,7 @@ $sticky-header-height: calc(50px + 3.3rem); padding-left: 0; position: sticky; - // sticky detail header is 50px + 3.3rem - top: calc(50px + 3.3rem); + top: calc($sticky-detail-header-height + $navbar-height); .sidebar-toolbar { padding-top: 15px; @@ -918,7 +918,6 @@ $sticky-header-height: calc(50px + 3.3rem); flex: 100% 0 0; height: calc(100vh - 4rem); max-height: calc(100vh - 4rem); - padding-top: 0; top: 0; } diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 9393397ed..c013b852a 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -1,9 +1,10 @@ // variables required by other scss files // this is calculated from the existing height -// TODO: we should set this explicitly in the navbar $navbar-height: 48.75px; +$sticky-detail-header-height: 50px; + @import "styles/theme"; @import "styles/range"; @import "styles/scrollbars"; @@ -55,7 +56,7 @@ body { @include media-breakpoint-down(xs) { @media (orientation: portrait) { - padding: 1rem 0 $navbar-height; + padding: 0.5rem 0 $navbar-height; } } } @@ -85,10 +86,10 @@ dd { .sticky.detail-header { display: block; - min-height: 50px; + min-height: $sticky-detail-header-height; padding: unset; position: fixed; - top: 3.3rem; + top: $navbar-height; z-index: 10; @media (max-width: 576px) { @@ -692,8 +693,7 @@ div.dropdown-menu { .badge { margin: unset; - // stylelint-disable declaration-no-important - white-space: normal !important; + white-space: normal; } } @@ -1025,6 +1025,9 @@ div.dropdown-menu { top: auto; } } + @include media-breakpoint-up(xl) { + height: $navbar-height; + } .navbar-toggler { padding: 0.5em 0; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index b69e5a763..3d342754f 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -81,6 +81,7 @@ "open_random": "Open Random", "optimise_database": "Optimise Database", "overwrite": "Overwrite", + "play": "Play", "play_random": "Play Random", "play_selected": "Play selected", "preview": "Preview", @@ -124,9 +125,12 @@ "set_image": "Set image…", "show": "Show", "show_configuration": "Show Configuration", + "show_results": "Show results", + "show_count_results": "Show {count} results", "sidebar": { "close": "Close sidebar", - "open": "Open sidebar" + "open": "Open sidebar", + "toggle": "Toggle sidebar" }, "skip": "Skip", "split": "Split", diff --git a/ui/v2.5/src/models/list-filter/filter-options.ts b/ui/v2.5/src/models/list-filter/filter-options.ts index 68bc23e79..32b86e786 100644 --- a/ui/v2.5/src/models/list-filter/filter-options.ts +++ b/ui/v2.5/src/models/list-filter/filter-options.ts @@ -1,7 +1,7 @@ import { CriterionOption } from "./criteria/criterion"; import { DisplayMode } from "./types"; -interface ISortByOption { +export interface ISortByOption { messageID: string; value: string; } diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index e7cf6a6eb..ac9d9de1e 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -521,6 +521,34 @@ export class ListFilterModel { public setPageSize(pageSize: number) { const ret = this.clone(); ret.itemsPerPage = pageSize; + ret.currentPage = 1; // reset to first page + return ret; + } + + public setSortBy(sortBy: string | undefined) { + const ret = this.clone(); + ret.sortBy = sortBy; + ret.currentPage = 1; // reset to first page + return ret; + } + + public toggleSortDirection() { + const ret = this.clone(); + + if (ret.sortDirection === SortDirectionEnum.Asc) { + ret.sortDirection = SortDirectionEnum.Desc; + } else { + ret.sortDirection = SortDirectionEnum.Asc; + } + + ret.currentPage = 1; // reset to first page + return ret; + } + + public reshuffleRandomSort() { + const ret = this.clone(); + ret.currentPage = 1; + ret.randomSeed = -1; return ret; }